1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Remove legacy crypto (#4653)

* Remove deprecated calls in `webrtc/call.ts`

* Throw error when legacy call was used

* Remove `MatrixClient.initLegacyCrypto` (#4620)

* Remove `MatrixClient.initLegacyCrypto`

* Remove `MatrixClient.initLegacyCrypto` in README.md

* Remove tests using `MatrixClient.initLegacyCrypto`

* Remove legacy crypto support in `sync` api (#4622)

* Remove deprecated `DeviceInfo` in `webrtc/call.ts` (#4654)

* chore(legacy call): Remove `DeviceInfo` usage

* refactor(legacy call): throw `GroupCallUnknownDeviceError` at the end of `initOpponentCrypto`

* Remove deprecated methods and attributes of `MatrixClient` (#4659)

* feat(legacy crypto)!: remove deprecated methods of `MatrixClient`

* test(legacy crypto): update existing tests to not use legacy crypto

- `Embedded.spec.ts`: casting since `encryptAndSendToDevices` is removed from `MatrixClient`.
- `room.spec.ts`: remove deprecated usage of `MatrixClient.crypto`
- `matrix-client.spec.ts` & `matrix-client-methods.spec.ts`: remove calls of deprecated methods of `MatrixClient`

* test(legacy crypto): remove test files using `MatrixClient` deprecated methods

* test(legacy crypto): update existing integ tests to run successfully

* feat(legacy crypto!): remove `ICreateClientOpts.deviceToImport`.

`ICreateClientOpts.deviceToImport` was used in the legacy cryto. The rust crypto doesn't support to import devices in this way.

* feat(legacy crypto!): remove `{get,set}GlobalErrorOnUnknownDevices`

`globalErrorOnUnknownDevices` is not used in the rust-crypto. The API is marked as unstable, we can remove it.

* Remove usage of legacy crypto in `event.ts` (#4666)

* feat(legacy crypto!): remove legacy crypto usage in `event.ts`

* test(legacy crypto): update event.spec.ts to not use legacy crypto types

* Remove legacy crypto export in `matrix.ts` (#4667)

* feat(legacy crypto!): remove legacy crypto export in `matrix.ts`

* test(legacy crypto): update `megolm-backup.spec.ts` to import directly `CryptoApi`

* Remove usage of legacy crypto in integ tests (#4669)

* Clean up legacy stores (#4663)

* feat(legacy crypto!): keep legacy methods used in lib olm migration

The rust cryto needs these legacy stores in order to do the migration from the legacy crypto to the rust crypto. We keep the following methods of the stores:
- Used in `libolm_migration.ts`.
- Needed in the legacy store tests.
- Needed in the rust crypto test migration.

* feat(legacy crypto): extract legacy crypto types in legacy stores

In order to be able to delete the legacy crypto, these stores shouldn't rely on the legacy crypto. We need to extract the used types.

* feat(crypto store): remove `CryptoStore` functions used only by tests

* test(crypto store): use legacy `MemoryStore` type

* Remove deprecated methods of `CryptoBackend` (#4671)

* feat(CryptoBackend)!: remove deprecated methods

* feat(rust-crypto)!: remove deprecated methods of `CryptoBackend`

* test(rust-crypto): remove tests of deprecated methods of `CryptoBackend`

* Remove usage of legacy crypto in `embedded.ts` (#4668)

The interface of `encryptAndSendToDevices` changes because `DeviceInfo` is from the legacy crypto. In fact `encryptAndSendToDevices` only need pairs of userId and deviceId.

* Remove legacy crypto files (#4672)

* fix(legacy store): fix legacy store typing

In https://github.com/matrix-org/matrix-js-sdk/pull/4663, the storeXXX methods were removed of the CryptoStore interface but they are used internally by IndexedDBCryptoStore.

* feat(legacy crypto)!: remove content of `crypto/*` except legacy stores

* test(legacy crypto): remove `spec/unit/crypto/*` except legacy store tests

* refactor: remove unused types

* doc: fix broken link

* doc: remove link tag when typedoc is unable to find the CryptoApi

* Clean up integ test after legacy crypto removal (#4682)

* test(crypto): remove `newBackendOnly` test closure

* test(crypto): fix duplicate test name

* test(crypto): remove `oldBackendOnly` test closure

* test(crypto): remove `rust-sdk` comparison

* test(crypto): remove iteration on `CRYPTO_BACKEND`

* test(crypto): remove old legacy comments and tests

* test(crypto): fix documentations and removed unused expect

* Restore broken link to `CryptoApi` (#4692)

* chore: fix linting and formatting due to merge

* Remove unused crypto type and missing doc (#4696)

* chore(crypto): remove unused types

* doc(crypto): add missing link

* test(call): add test when crypto is enabled
This commit is contained in:
Florian D
2025-02-07 13:31:40 +01:00
committed by GitHub
parent 6b93e11e2c
commit 810f7142e6
92 changed files with 491 additions and 31651 deletions

View File

@ -86,12 +86,11 @@ module.exports = {
// Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this.
"jest/no-disabled-tests": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"],
additionalTestBlockFunctions: ["beforeAll", "beforeEach"],
},
],
},

View File

@ -307,8 +307,6 @@ Then visit `http://localhost:8005` to see the API docs.
## Initialization
**Do not use `matrixClient.initLegacyCrypto()`. This method is deprecated and no longer maintained.**
To initialize the end-to-end encryption support in the matrix client:
```javascript

View File

@ -26,7 +26,6 @@ import MockHttpBackend from "matrix-mock-request";
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
import type { IE2EKeyReceiver } from "./test-utils/E2EKeyReceiver";
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
import { logger } from "../src/logger";
import { syncPromise } from "./test-utils/test-utils";
import { createClient, type IStartClientOpts } from "../src/matrix";
@ -36,7 +35,6 @@ import {
type MatrixClient,
PendingEventOrdering,
} from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { type IKeysUploadResponse, type IUploadKeysRequest } from "../src/client";
import { type ISyncResponder } from "./test-utils/SyncResponder";
@ -60,10 +58,6 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder {
sessionStoreBackend?: Storage,
options?: Partial<ICreateClientOpts>,
) {
if (sessionStoreBackend === undefined) {
sessionStoreBackend = new MockStorageApi() as unknown as Storage;
}
this.httpBackend = new MockHttpBackend();
const fullOptions: ICreateClientOpts = {
@ -74,10 +68,6 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder {
fetchFn: this.httpBackend.fetchFn as typeof globalThis.fetch,
...options,
};
if (!fullOptions.cryptoStore) {
// expose this so the tests can get to it
fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend);
}
this.client = createClient(fullOptions);
this.deviceKeys = null;

View File

@ -18,8 +18,8 @@ import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, type InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { type AuthDict, createClient, CryptoEvent, type MatrixClient } from "../../../src";
import { syncPromise } from "../../test-utils/test-utils";
import { type AuthDict, createClient, type MatrixClient } from "../../../src";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts";
import { type CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
@ -37,6 +37,7 @@ import {
import * as testData from "../../test-utils/test-data";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
import { CryptoEvent } from "../../../src/crypto-api";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@ -54,11 +55,7 @@ const TEST_DEVICE_ID = "xzcvb";
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
// backend. Once we drop support for legacy crypto, it will go away.
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
describe("cross-signing", () => {
let aliceClient: MatrixClient;
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
@ -107,7 +104,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
body: { errcode: "M_NOT_FOUND" },
});
await initCrypto(aliceClient);
await aliceClient.initRustCrypto();
},
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
10000,
@ -162,7 +159,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
);
});
newBackendOnly("get cross signing keys from secret storage and import them", async () => {
it("get cross signing keys from secret storage and import them", async () => {
// Return public cross signing keys
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
@ -263,7 +260,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
expect(calls.length).toEqual(0);
});
newBackendOnly("will upload existing cross-signing keys to an established secret storage", async () => {
it("will upload existing cross-signing keys to an established secret storage", async () => {
// This rather obscure codepath covers the case that:
// - 4S is set up and working
// - our device has private cross-signing keys, but has not published them to 4S
@ -420,9 +417,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
function awaitCrossSigningKeysUpload() {
return new Promise<any>((resolve) => {
fetchMock.post(
// legacy crypto uses /unstable/; /v3/ is correct
{
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"),
name: "upload-keys",
},
(url, options) => {
@ -475,9 +471,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
await aliceClient.startClient();
await syncPromise(aliceClient);
// Wait for legacy crypto to find the device
await jest.advanceTimersByTimeAsync(10);
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([aliceClient.getSafeUserId()]);
expect(devices.get(aliceClient.getSafeUserId())!.has(testData.TEST_DEVICE_ID)).toBeTruthy();
});

File diff suppressed because it is too large Load Diff

View File

@ -218,8 +218,8 @@ async function initializeSecretStorage(
privateKey: new Uint8Array(32),
};
}
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
await matrixClient.bootstrapSecretStorage({
await matrixClient.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true });
await matrixClient.getCrypto()!.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: false,

View File

@ -21,7 +21,6 @@ import { type Mocked } from "jest-mock";
import {
createClient,
type Crypto,
encodeBase64,
type ICreateClientOpts,
type IEvent,
@ -33,18 +32,12 @@ 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,
CRYPTO_BACKENDS,
type InitCrypto,
syncPromise,
} from "../../test-utils/test-utils";
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 } from "../../../src/crypto-api";
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;
@ -114,14 +107,7 @@ function mockUploadEmitter(
return emitter;
}
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;
const newBackendOnly = backend === "libolm" ? test.skip : test;
const isNewBackend = backend === "rust-sdk";
describe("megolm-keys backup", () => {
let aliceClient: MatrixClient;
/** an object which intercepts `/sync` requests on the test homeserver */
let syncResponder: SyncResponder;
@ -167,7 +153,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
deviceId: TEST_DEVICE_ID,
...opts,
});
await initCrypto(client);
await client.initRustCrypto();
return client;
}
@ -248,11 +234,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
// On the first decryption attempt, decryption fails.
await awaitDecryption(event);
expect(event.decryptionFailureReason).toEqual(
isNewBackend
? DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP
: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
);
expect(event.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
// Eventually, decryption succeeds.
await awaitDecryption(event, { waitOnDecryptionFailure: true });
@ -312,7 +294,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
describe("recover from backup", () => {
let aliceCrypto: Crypto.CryptoApi;
let aliceCrypto: CryptoApi;
beforeEach(async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
@ -344,43 +326,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
const check = await aliceCrypto.checkKeyBackupAndEnable();
let onKeyCached: () => void;
const awaitKeyCached = new Promise<void>((resolve) => {
onKeyCached = resolve;
});
await aliceCrypto.storeSessionBackupPrivateKey(
decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58),
check!.backupInfo!.version!,
);
const result = await advanceTimersUntil(
isNewBackend
? aliceCrypto.restoreKeyBackup()
: aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
cacheCompleteCallback: () => onKeyCached(),
},
),
);
const result = await advanceTimersUntil(aliceCrypto.restoreKeyBackup());
expect(result.imported).toStrictEqual(1);
if (isNewBackend) return;
await awaitKeyCached;
// The key should be now cached
const afterCache = await advanceTimersUntil(
aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!),
);
expect(afterCache.imported).toStrictEqual(1);
});
/**
@ -413,13 +366,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
it("Should import full backup in chunks", async function () {
const importMockImpl = jest.fn();
if (isNewBackend) {
// @ts-ignore - mock a private method for testing purpose
jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
} else {
// @ts-ignore - mock a private method for testing purpose
jest.spyOn(aliceCrypto, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
}
// @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]);
@ -434,19 +382,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
);
const progressCallback = jest.fn();
const result = await (isNewBackend
? aliceCrypto.restoreKeyBackup({
progressCallback,
})
: aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
progressCallback,
},
));
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
@ -489,13 +427,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
// Ok for other chunks
.mockResolvedValue(undefined);
if (isNewBackend) {
// @ts-ignore - mock a private method for testing purpose
jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
} else {
// @ts-ignore - mock a private method for testing purpose
jest.spyOn(aliceCrypto, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
}
// @ts-ignore - mock a private method for testing purpose
jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
const { response, expectedTotal } = createBackupDownloadResponse([100, 300]);
@ -508,17 +441,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
);
const progressCallback = jest.fn();
const result = await (isNewBackend
? aliceCrypto.restoreKeyBackup({ progressCallback })
: aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
progressCallback,
},
));
const result = await aliceCrypto.restoreKeyBackup({ progressCallback });
expect(result.total).toStrictEqual(expectedTotal);
// A chunk failed to import
@ -574,67 +497,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
check!.backupInfo!.version!,
);
const result = await (isNewBackend
? aliceCrypto.restoreKeyBackup()
: aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
));
const result = await aliceCrypto.restoreKeyBackup();
expect(result.total).toStrictEqual(expectedTotal);
// A chunk failed to import
expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount);
});
oldBackendOnly("recover specific session from backup", async function () {
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
testData.CURVE25519_KEY_BACKUP_DATA,
);
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 check = await aliceCrypto.checkKeyBackupAndEnable();
const result = await advanceTimersUntil(
aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
ROOM_ID,
testData.MEGOLM_SESSION_DATA.session_id,
check!.backupInfo!,
),
);
expect(result.imported).toStrictEqual(1);
});
newBackendOnly(
"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);
},
);
oldBackendOnly("Fails on bad recovery key", async function () {
const fullBackup = {
rooms: {
[ROOM_ID]: {
@ -644,22 +517,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
},
},
};
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
const check = await aliceCrypto.checkKeyBackupAndEnable();
await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage();
const decryptionKey = await aliceCrypto.getSessionBackupPrivateKey();
expect(encodeBase64(decryptionKey!)).toStrictEqual(testData.BACKUP_DECRYPTION_KEY_BASE64);
await expect(
aliceClient.restoreKeyBackupWithRecoveryKey(
"EsTx A7Xn aNFF k3jH zpV3 MQoN LJEg mscC HecF 982L wC77 mYQD",
undefined,
undefined,
check!.backupInfo!,
),
).rejects.toThrow();
const result = await aliceCrypto.restoreKeyBackup();
expect(result.imported).toStrictEqual(1);
});
newBackendOnly("Should throw an error if the decryption key is not found in cache", async () => {
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");
});
});
@ -968,7 +836,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version);
});
newBackendOnly("getKeyBackupInfo() should not return a backup if the active backup has been deleted", async () => {
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}`, {});

View File

@ -1,705 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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.
*/
/* This file consists of a set of integration tests which try to simulate
* communication via an Olm-encrypted room between two users, Alice and Bob.
*
* Note that megolm (group) conversation is not tested here.
*
* See also `crypto.spec.js`.
*/
// load olm before the sdk if possible
import "../../olm-loader";
import type { Session } from "@matrix-org/olm";
import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto";
import { logger } from "../../../src/logger";
import * as testUtils from "../../test-utils/test-utils";
import { TestClient } from "../../TestClient";
import {
CRYPTO_ENABLED,
type IClaimKeysRequest,
type IQueryKeysRequest,
type IUploadKeysRequest,
} from "../../../src/client";
import {
ClientEvent,
type IContent,
type ISendEventResponse,
type MatrixClient,
MatrixEvent,
MsgType,
} from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { KnownMembership } from "../../../src/@types/membership";
let aliTestClient: TestClient;
const roomId = "!room:localhost";
const aliUserId = "@ali:localhost";
const aliDeviceId = "zxcvb";
const aliAccessToken = "aseukfgwef";
let bobTestClient: TestClient;
const bobUserId = "@bob:localhost";
const bobDeviceId = "bvcxz";
const bobAccessToken = "fewgfkuesa";
let aliMessages: IContent[];
let bobMessages: IContent[];
type OlmPayload = ReturnType<Session["encrypt"]>;
async function bobUploadsDeviceKeys(): Promise<void> {
bobTestClient.expectDeviceKeyUpload();
await bobTestClient.httpBackend.flushAllExpected();
expect(Object.keys(bobTestClient.deviceKeys!).length).not.toEqual(0);
}
/**
* Set an expectation that querier will query uploader's keys; then flush the http request.
*
* @returns resolves once the http request has completed.
*/
function expectQueryKeys(querier: TestClient, uploader: TestClient): Promise<number> {
// can't query keys before bob has uploaded them
expect(uploader.deviceKeys).toBeTruthy();
const uploaderKeys: Record<string, IDeviceKeys> = {};
uploaderKeys[uploader.deviceId!] = uploader.deviceKeys!;
querier.httpBackend.when("POST", "/keys/query").respond(200, function (_path, content: IQueryKeysRequest) {
expect(content.device_keys![uploader.userId!]).toEqual([]);
const result: Record<string, Record<string, IDeviceKeys>> = {};
result[uploader.userId!] = uploaderKeys;
return { device_keys: result };
});
return querier.httpBackend.flush("/keys/query", 1);
}
const expectAliQueryKeys = () => expectQueryKeys(aliTestClient, bobTestClient);
const expectBobQueryKeys = () => expectQueryKeys(bobTestClient, aliTestClient);
/**
* Set an expectation that ali will claim one of bob's keys; then flush the http request.
*
* @returns resolves once the http request has completed.
*/
async function expectAliClaimKeys(): Promise<void> {
const keys = await bobTestClient.awaitOneTimeKeyUpload();
aliTestClient.httpBackend.when("POST", "/keys/claim").respond(200, function (_path, content: IClaimKeysRequest) {
const claimType = content.one_time_keys![bobUserId][bobDeviceId];
expect(claimType).toEqual("signed_curve25519");
let keyId = "";
for (keyId in keys) {
if (bobTestClient.oneTimeKeys!.hasOwnProperty(keyId)) {
if (keyId.indexOf(claimType + ":") === 0) {
break;
}
}
}
const result: Record<string, Record<string, Record<string, IOneTimeKey>>> = {};
result[bobUserId] = {};
result[bobUserId][bobDeviceId] = {};
result[bobUserId][bobDeviceId][keyId] = keys[keyId];
return { one_time_keys: result };
});
// it can take a while to process the key query, so give it some extra
// time, and make sure the claim actually happens rather than ploughing on
// confusingly.
const r = await aliTestClient.httpBackend.flush("/keys/claim", 1, 500);
expect(r).toEqual(1);
}
async function aliDownloadsKeys(): Promise<void> {
// can't query keys before bob has uploaded them
expect(bobTestClient.getSigningKey()).toBeTruthy();
const p1 = async () => {
await aliTestClient.client.downloadKeys([bobUserId]);
const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId);
expect(devices.length).toEqual(1);
expect(devices[0].deviceId).toEqual("bvcxz");
};
const p2 = expectAliQueryKeys;
// check that the localStorage is updated as we expect (not sure this is
// an integration test, but meh)
await Promise.all([p1(), p2()]);
await aliTestClient.client.crypto!.deviceList.saveIfDirty();
// @ts-ignore - protected
aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data!.devices[bobUserId]!;
expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys!.keys);
expect(devices[bobDeviceId].verified).toBe(DeviceInfo.DeviceVerification.UNVERIFIED);
});
}
async function clientEnablesEncryption(client: MatrixClient): Promise<void> {
await client.setRoomEncryption(roomId, {
algorithm: "m.olm.v1.curve25519-aes-sha2",
});
expect(client.isRoomEncrypted(roomId)).toBeTruthy();
}
const aliEnablesEncryption = () => clientEnablesEncryption(aliTestClient.client);
const bobEnablesEncryption = () => clientEnablesEncryption(bobTestClient.client);
/**
* Ali sends a message, first claiming e2e keys. Set the expectations and
* check the results.
*
* @returns which resolves to the ciphertext for Bob's device.
*/
async function aliSendsFirstMessage(): Promise<OlmPayload> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, ciphertext] = await Promise.all([
sendMessage(aliTestClient.client),
expectAliQueryKeys().then(expectAliClaimKeys).then(expectAliSendMessageRequest),
]);
return ciphertext;
}
/**
* Ali sends a message without first claiming e2e keys. Set the expectations
* and check the results.
*
* @returns which resolves to the ciphertext for Bob's device.
*/
async function aliSendsMessage(): Promise<OlmPayload> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, ciphertext] = await Promise.all([sendMessage(aliTestClient.client), expectAliSendMessageRequest()]);
return ciphertext;
}
/**
* Bob sends a message, first querying (but not claiming) e2e keys. Set the
* expectations and check the results.
*
* @returns which resolves to the ciphertext for Ali's device.
*/
async function bobSendsReplyMessage(): Promise<OlmPayload> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, ciphertext] = await Promise.all([
sendMessage(bobTestClient.client),
expectBobQueryKeys().then(expectBobSendMessageRequest),
]);
return ciphertext;
}
/**
* Set an expectation that Ali will send a message, and flush the request
*
* @returns which resolves to the ciphertext for Bob's device.
*/
async function expectAliSendMessageRequest(): Promise<OlmPayload> {
const content = await expectSendMessageRequest(aliTestClient.httpBackend);
aliMessages.push(content);
expect(Object.keys(content.ciphertext)).toEqual([bobTestClient.getDeviceKey()]);
const ciphertext = content.ciphertext[bobTestClient.getDeviceKey()];
expect(ciphertext).toBeTruthy();
return ciphertext;
}
/**
* Set an expectation that Bob will send a message, and flush the request
*
* @returns which resolves to the ciphertext for Bob's device.
*/
async function expectBobSendMessageRequest(): Promise<OlmPayload> {
const content = await expectSendMessageRequest(bobTestClient.httpBackend);
bobMessages.push(content);
const aliKeyId = "curve25519:" + aliDeviceId;
const aliDeviceCurve25519Key = aliTestClient.deviceKeys!.keys[aliKeyId];
expect(Object.keys(content.ciphertext)).toEqual([aliDeviceCurve25519Key]);
const ciphertext = content.ciphertext[aliDeviceCurve25519Key];
expect(ciphertext).toBeTruthy();
return ciphertext;
}
function sendMessage(client: MatrixClient): Promise<ISendEventResponse> {
return client.sendMessage(roomId, { msgtype: MsgType.Text, body: "Hello, World" });
}
async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise<IContent> {
const path = "/send/m.room.encrypted/";
const prom = new Promise<IContent>((resolve) => {
httpBackend.when("PUT", path).respond(200, function (_path, content) {
resolve(content);
return {
event_id: "asdfgh",
};
});
});
// it can take a while to process the key query
await httpBackend.flush(path, 1);
return prom;
}
function aliRecvMessage(): Promise<void> {
const message = bobMessages.shift()!;
return recvMessage(aliTestClient.httpBackend, aliTestClient.client, bobUserId, message);
}
function bobRecvMessage(): Promise<void> {
const message = aliMessages.shift()!;
return recvMessage(bobTestClient.httpBackend, bobTestClient.client, aliUserId, message);
}
async function recvMessage(
httpBackend: TestClient["httpBackend"],
client: MatrixClient,
sender: string,
message: IContent,
): Promise<void> {
const syncData = {
next_batch: "x",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
testUtils.mkEvent({
type: "m.room.encrypted",
room: roomId,
content: message,
sender: sender,
}),
],
},
},
},
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
const eventPromise = new Promise<MatrixEvent>((resolve) => {
const onEvent = function (event: MatrixEvent) {
// ignore the m.room.member events
if (event.getType() == "m.room.member") {
return;
}
logger.log(client.credentials.userId + " received event", event);
client.removeListener(ClientEvent.Event, onEvent);
resolve(event);
};
client.on(ClientEvent.Event, onEvent);
});
await httpBackend.flushAllExpected();
const preDecryptionEvent = await eventPromise;
expect(preDecryptionEvent.isEncrypted()).toBeTruthy();
// it may still be being decrypted
const event = await testUtils.awaitDecryption(preDecryptionEvent);
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent()).toMatchObject({
msgtype: "m.text",
body: "Hello, World",
});
expect(event.isEncrypted()).toBeTruthy();
}
/**
* Send an initial sync response to the client (which just includes the member
* list for our test room).
*
* @returns which resolves when the sync has been flushed.
*/
function firstSync(testClient: TestClient): Promise<void> {
// send a sync response including our test room.
const syncData = {
next_batch: "x",
rooms: {
join: {
[roomId]: {
state: {
events: [
testUtils.mkMembership({
mship: KnownMembership.Join,
user: aliUserId,
}),
testUtils.mkMembership({
mship: KnownMembership.Join,
user: bobUserId,
}),
],
},
timeline: {
events: [],
},
},
},
},
};
testClient.httpBackend.when("GET", "/sync").respond(200, syncData);
return testClient.flushSync();
}
describe("MatrixClient crypto", () => {
if (!CRYPTO_ENABLED) {
return;
}
beforeEach(async () => {
aliTestClient = new TestClient(aliUserId, aliDeviceId, aliAccessToken);
await aliTestClient.client.initLegacyCrypto();
bobTestClient = new TestClient(bobUserId, bobDeviceId, bobAccessToken);
await bobTestClient.client.initLegacyCrypto();
aliMessages = [];
bobMessages = [];
});
afterEach(() => {
aliTestClient.httpBackend.verifyNoOutstandingExpectation();
bobTestClient.httpBackend.verifyNoOutstandingExpectation();
return Promise.all([aliTestClient.stop(), bobTestClient.stop()]);
});
it("Bob uploads device keys", bobUploadsDeviceKeys);
it("handles failures to upload device keys", async () => {
// since device keys are uploaded asynchronously, there's not really much to do here other than fail the
// upload.
bobTestClient.httpBackend.when("POST", "/keys/upload").fail(0, new Error("bleh"));
await bobTestClient.httpBackend.flushAllExpected();
});
it("Ali downloads Bobs device keys", async () => {
await bobUploadsDeviceKeys();
await aliDownloadsKeys();
});
it("Ali gets keys with an invalid signature", async () => {
await bobUploadsDeviceKeys();
// tamper bob's keys
const bobDeviceKeys = bobTestClient.deviceKeys!;
expect(bobDeviceKeys.keys["curve25519:" + bobDeviceId]).toBeTruthy();
bobDeviceKeys.keys["curve25519:" + bobDeviceId] += "abc";
await Promise.all([aliTestClient.client.downloadKeys([bobUserId]), expectAliQueryKeys()]);
const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId);
// should get an empty list
expect(devices).toEqual([]);
});
it("Ali gets keys with an incorrect userId", async () => {
const eveUserId = "@eve:localhost";
const bobDeviceKeys = {
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "bvcxz",
keys: {
"ed25519:bvcxz": "pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q",
"curve25519:bvcxz": "7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ",
},
user_id: "@eve:localhost",
signatures: {
"@eve:localhost": {
"ed25519:bvcxz":
"CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG" + "0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg",
},
},
};
const bobKeys: Record<string, typeof bobDeviceKeys> = {};
bobKeys[bobDeviceId] = bobDeviceKeys;
aliTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { [bobUserId]: bobKeys } });
await Promise.all([
aliTestClient.client.downloadKeys([bobUserId, eveUserId]),
aliTestClient.httpBackend.flush("/keys/query", 1),
]);
const [bobDevices, eveDevices] = await Promise.all([
aliTestClient.client.getStoredDevicesForUser(bobUserId),
aliTestClient.client.getStoredDevicesForUser(eveUserId),
]);
// should get an empty list
expect(bobDevices).toEqual([]);
expect(eveDevices).toEqual([]);
});
it("Ali gets keys with an incorrect deviceId", async () => {
const bobDeviceKeys = {
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "bad_device",
keys: {
"ed25519:bad_device": "e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0",
"curve25519:bad_device": "YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc",
},
user_id: "@bob:localhost",
signatures: {
"@bob:localhost": {
"ed25519:bad_device":
"fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A" + "me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ",
},
},
};
const bobKeys: Record<string, typeof bobDeviceKeys> = {};
bobKeys[bobDeviceId] = bobDeviceKeys;
aliTestClient.httpBackend.when("POST", "/keys/query").respond(200, { device_keys: { [bobUserId]: bobKeys } });
await Promise.all([
aliTestClient.client.downloadKeys([bobUserId]),
aliTestClient.httpBackend.flush("/keys/query", 1),
]);
const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId);
// should get an empty list
expect(devices).toEqual([]);
});
it("Bob starts his client and uploads device keys and one-time keys", async () => {
await bobTestClient.start();
const keys = await bobTestClient.awaitOneTimeKeyUpload();
expect(Object.keys(keys).length).toEqual(5);
expect(Object.keys(bobTestClient.deviceKeys!).length).not.toEqual(0);
});
it("Ali sends a message", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
});
it("Bob receives a message", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
await bobRecvMessage();
});
it("Bob receives a message with a bogus sender", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
const message = aliMessages.shift()!;
const syncData = {
next_batch: "x",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
testUtils.mkEvent({
type: "m.room.encrypted",
room: roomId,
content: message,
sender: "@bogus:sender",
}),
],
},
},
},
},
};
bobTestClient.httpBackend.when("GET", "/sync").respond(200, syncData);
const eventPromise = new Promise<MatrixEvent>((resolve) => {
const onEvent = function (event: MatrixEvent) {
logger.log(bobUserId + " received event", event);
resolve(event);
};
bobTestClient.client.once(ClientEvent.Event, onEvent);
});
await bobTestClient.httpBackend.flushAllExpected();
const preDecryptionEvent = await eventPromise;
expect(preDecryptionEvent.isEncrypted()).toBeTruthy();
// it may still be being decrypted
const event = await testUtils.awaitDecryption(preDecryptionEvent);
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().msgtype).toEqual("m.bad.encrypted");
});
it("Ali blocks Bob's device", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliDownloadsKeys();
aliTestClient.client.setDeviceBlocked(bobUserId, bobDeviceId, true);
const p1 = sendMessage(aliTestClient.client);
const p2 = expectSendMessageRequest(aliTestClient.httpBackend).then(function (sentContent) {
// no unblocked devices, so the ciphertext should be empty
expect(sentContent.ciphertext).toEqual({});
});
await Promise.all([p1, p2]);
});
it("Bob receives two pre-key messages", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
await bobRecvMessage();
await aliSendsMessage();
await bobRecvMessage();
});
it("Bob replies to the message", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
bobTestClient.expectKeyQuery({ device_keys: { [bobUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await firstSync(bobTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
bobTestClient.httpBackend.when("POST", "/keys/query").respond(200, {});
await bobRecvMessage();
await bobEnablesEncryption();
const ciphertext = await bobSendsReplyMessage();
expect(ciphertext.type).toEqual(1);
await aliRecvMessage();
});
it("Ali does a key query when encryption is enabled", async () => {
// enabling encryption in the room should make alice download devices
// for both members.
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await firstSync(aliTestClient);
const syncData = {
next_batch: "2",
rooms: {
join: {
[roomId]: {
state: {
events: [
testUtils.mkEvent({
type: "m.room.encryption",
skey: "",
content: {
algorithm: "m.olm.v1.curve25519-aes-sha2",
},
}),
],
},
},
},
},
};
aliTestClient.httpBackend.when("GET", "/sync").respond(200, syncData);
await aliTestClient.httpBackend.flush("/sync", 1);
aliTestClient.expectKeyQuery({
device_keys: {
[bobUserId]: {},
},
failures: {},
});
await aliTestClient.httpBackend.flushAllExpected();
});
it("Upload new oneTimeKeys based on a /sync request - no count-asking", async () => {
// Send a response which causes a key upload
const httpBackend = aliTestClient.httpBackend;
const syncDataEmpty = {
next_batch: "a",
device_one_time_keys_count: {
signed_curve25519: 0,
},
};
// enqueue expectations:
// * Sync with empty one_time_keys => upload keys
logger.log(aliTestClient + ": starting");
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
aliTestClient.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
httpBackend.when("GET", "/sync").respond(200, syncDataEmpty);
await Promise.all([aliTestClient.client.startClient({}), httpBackend.flushAllExpected()]);
logger.log(aliTestClient + ": started");
httpBackend.when("POST", "/keys/upload").respond(200, (_path, content: IUploadKeysRequest) => {
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
expect(Object.keys(content.one_time_keys!).length).toBeGreaterThanOrEqual(1);
// cancel futher calls by telling the client
// we have more than we need
return {
one_time_key_counts: {
signed_curve25519: 70,
},
};
});
await httpBackend.flushAllExpected();
});
it("Checks for outgoing room key requests for a given event's session", async () => {
const eventA0 = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: "m.megolm.v1.aes-sha2",
session_id: "sessionid",
sender_key: "senderkey",
},
});
const eventA1 = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: "m.megolm.v1.aes-sha2",
session_id: "sessionid",
sender_key: "senderkey",
},
});
const eventB = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: "m.megolm.v1.aes-sha2",
session_id: "othersessionid",
sender_key: "senderkey",
},
});
const nonEncryptedEvent = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {},
});
aliTestClient.client.crypto?.onSyncCompleted({});
await aliTestClient.client.cancelAndResendEventRoomKeyRequest(eventA0);
expect(await aliTestClient.client.getOutgoingRoomKeyRequest(eventA1)).not.toBeNull();
expect(await aliTestClient.client.getOutgoingRoomKeyRequest(eventB)).toBeNull();
expect(await aliTestClient.client.getOutgoingRoomKeyRequest(nonEncryptedEvent)).toBeNull();
});
});

View File

@ -18,12 +18,13 @@ import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import fetchMock from "fetch-mock-jest";
import { createClient, CryptoEvent, IndexedDBCryptoStore } from "../../../src";
import { createClient, IndexedDBCryptoStore } from "../../../src";
import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump";
import { MSK_NOT_CACHED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump";
import { IDENTITY_NOT_TRUSTED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/unverified";
import { FULL_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/full_account";
import { EMPTY_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/empty_account";
import { CryptoEvent } from "../../../src/crypto-api";
jest.setTimeout(15000);

View File

@ -18,7 +18,7 @@ import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, getSyncResponse, type InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { createClient, type MatrixClient } from "../../../src";
import * as testData from "../../test-utils/test-data";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
@ -38,7 +38,7 @@ afterEach(() => {
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("to-device-messages (%s)", (backend: string, initCrypto: InitCrypto) => {
describe("to-device-messages", () => {
let aliceClient: MatrixClient;
/** an object which intercepts `/keys/query` requests on the test homeserver */
@ -81,7 +81,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("to-device-messages (%s)", (backe
{ filter_id: "fid" },
);
await initCrypto(aliceClient);
await aliceClient.initRustCrypto();
},
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
10000,

View File

@ -25,7 +25,6 @@ import Olm from "@matrix-org/olm";
import type FetchMock from "fetch-mock";
import {
createClient,
CryptoEvent,
DeviceVerification,
type IContent,
type ICreateClientOpts,
@ -45,14 +44,7 @@ import {
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { defer, escapeRegExp } from "../../../src/utils";
import {
awaitDecryption,
CRYPTO_BACKENDS,
emitPromise,
getSyncResponse,
type InitCrypto,
syncPromise,
} from "../../test-utils/test-utils";
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
BACKUP_DECRYPTION_KEY_BASE64,
@ -81,7 +73,7 @@ import {
getTestOlmAccountKeys,
type ToDeviceEvent,
} from "./olm-utils";
import { type KeyBackupInfo } from "../../../src/crypto-api";
import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api";
import { encodeBase64 } from "../../../src/base64";
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
@ -118,11 +110,7 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
* to provide the most effective integration tests possible.
*/
// we test with both crypto stacks...
describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => {
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
// backend. Once we drop support for legacy crypto, it will go away.
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
describe("verification", () => {
/** the client under test */
let aliceClient: MatrixClient;
@ -432,9 +420,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(requests[0].transactionId).toEqual(transactionId);
}
// legacy crypto picks devices individually; rust crypto uses a broadcast message
const toDeviceMessage =
requestBody.messages[TEST_USER_ID]["*"] ?? requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
// rust crypto uses a broadcast message
const toDeviceMessage = requestBody.messages[TEST_USER_ID]["*"];
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
});
@ -522,18 +509,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
reciprocateQRCodeCallbacks.confirm();
await sendToDevicePromise;
// at this point, on legacy crypto, the master key is already marked as trusted, and the request is "Done".
// Rust crypto, on the other hand, waits for the 'done' to arrive from the other side.
// Rust crypto waits for the 'done' to arrive from the other side.
if (request.phase === VerificationPhase.Done) {
// legacy crypto: we're all done
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
// eslint-disable-next-line jest/no-conditional-expect
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
await verificationPromise;
} else {
// rust crypto: still in flight
// eslint-disable-next-line jest/no-conditional-expect
expect(request.phase).toEqual(VerificationPhase.Started);
}
// the dummy device replies with its own 'done'
@ -569,7 +550,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(qrCodeBuffer).toBeUndefined();
});
newBackendOnly("can verify another by scanning their QR code", async () => {
it("can verify another by scanning their QR code", async () => {
aliceClient = await startTestClient();
// we need cross-signing keys for a QR code verification
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
@ -907,7 +888,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
describe("Send verification request in DM", () => {
beforeEach(async () => {
aliceClient = await startTestClient();
aliceClient.setGlobalErrorOnUnknownDevices(false);
e2eKeyResponder.addCrossSigningData(BOB_SIGNED_CROSS_SIGNING_KEYS_DATA);
e2eKeyResponder.addDeviceKeys(BOB_SIGNED_TEST_DEVICE_DATA);
@ -990,21 +970,18 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
testOlmAccount.create();
aliceClient = await startTestClient();
aliceClient.setGlobalErrorOnUnknownDevices(false);
syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID]));
await syncPromise(aliceClient);
// Rust crypto requires the sender's device keys before it accepts a
// verification request.
if (backend === "rust-sdk") {
const crypto = aliceClient.getCrypto()!;
const crypto = aliceClient.getCrypto()!;
const bobDeviceKeys = getTestOlmAccountKeys(testOlmAccount, BOB_TEST_USER_ID, "BobDevice");
e2eKeyResponder.addDeviceKeys(bobDeviceKeys);
syncResponder.sendOrQueueSyncResponse({ device_lists: { changed: [BOB_TEST_USER_ID] } });
await syncPromise(aliceClient);
await crypto.getUserDeviceInfo([BOB_TEST_USER_ID]);
}
const bobDeviceKeys = getTestOlmAccountKeys(testOlmAccount, BOB_TEST_USER_ID, "BobDevice");
e2eKeyResponder.addDeviceKeys(bobDeviceKeys);
syncResponder.sendOrQueueSyncResponse({ device_lists: { changed: [BOB_TEST_USER_ID] } });
await syncPromise(aliceClient);
await crypto.getUserDeviceInfo([BOB_TEST_USER_ID]);
});
/**
@ -1152,43 +1129,40 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(request?.otherUserId).toBe("@bob:xyz");
});
newBackendOnly(
"If the verification request is not decrypted within 5 minutes, the request is ignored",
async () => {
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
it("If the verification request is not decrypted within 5 minutes, the request is ignored", async () => {
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event, but don't send it yet
const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession);
// make the room_key event, but don't send it yet
const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession);
// Add verification request from Bob to Alice in the DM between them
returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession));
// Add verification request from Bob to Alice in the DM between them
returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession));
// Wait for the sync response to be processed
await syncPromise(aliceClient);
// Wait for the sync response to be processed
await syncPromise(aliceClient);
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
const matrixEvent = room.getLiveTimeline().getEvents()[0];
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
const matrixEvent = room.getLiveTimeline().getEvents()[0];
// wait for a first attempt at decryption: should fail
await awaitDecryption(matrixEvent);
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
// wait for a first attempt at decryption: should fail
await awaitDecryption(matrixEvent);
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
// Advance time by 5mins, the verification request should be ignored after that
jest.advanceTimersByTime(5 * 60 * 1000);
// Advance time by 5mins, the verification request should be ignored after that
jest.advanceTimersByTime(5 * 60 * 1000);
// Send Bob the room keys
returnToDeviceMessageFromSync(toDeviceEvent);
// Send Bob the room keys
returnToDeviceMessageFromSync(toDeviceEvent);
// Wait for the message to be decrypted
await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
// Wait for the message to be decrypted
await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// the request should not be present
expect(request).not.toBeDefined();
},
);
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// the request should not be present
expect(request).not.toBeDefined();
});
});
describe("Secrets are gossiped after verification", () => {
@ -1260,7 +1234,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
fetchMock.mockReset();
});
newBackendOnly("Should request cross signing keys after verification", async () => {
it("Should request cross signing keys after verification", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
@ -1271,7 +1245,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
await requestPromises.get("m.cross_signing.self_signing");
});
newBackendOnly("Should accept the backup decryption key gossip if valid", async () => {
it("Should accept the backup decryption key gossip if valid", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
@ -1290,7 +1264,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64);
});
newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => {
it("Should not accept the backup decryption key gossip if private key do not match", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
@ -1311,7 +1285,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(cachedKey).toBeNull();
});
newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => {
it("Should not accept the backup decryption key gossip if backup not trusted", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
@ -1335,7 +1309,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(cachedKey).toBeNull();
});
newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => {
it("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
@ -1360,7 +1334,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(cachedKey).toBeNull();
});
newBackendOnly("Should not accept an invalid backup decryption key", async () => {
it("Should not accept an invalid backup decryption key", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
@ -1482,7 +1456,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
deviceId: "device_under_test",
...opts,
});
await initCrypto(client);
await client.initRustCrypto();
await client.startClient();
return client;
}

View File

@ -1,406 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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 { TestClient } from "../TestClient";
import * as testUtils from "../test-utils/test-utils";
import { logger } from "../../src/logger";
import { KnownMembership } from "../../src/@types/membership";
const ROOM_ID = "!room:id";
/**
* get a /sync response which contains a single e2e room (ROOM_ID), with the
* members given
*
* @returns sync response
*/
function getSyncResponse(roomMembers: string[]) {
const stateEvents = [
testUtils.mkEvent({
type: "m.room.encryption",
skey: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
}),
];
Array.prototype.push.apply(
stateEvents,
roomMembers.map((m) =>
testUtils.mkMembership({
mship: KnownMembership.Join,
sender: m,
}),
),
);
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[ROOM_ID]: {
state: {
events: stateEvents,
},
},
},
},
};
return syncResponse;
}
describe("DeviceList management:", function () {
if (!globalThis.Olm) {
logger.warn("not running deviceList tests: Olm not present");
return;
}
let aliceTestClient: TestClient;
let sessionStoreBackend: Storage;
async function createTestClient() {
const testClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs", sessionStoreBackend);
await testClient.client.initLegacyCrypto();
return testClient;
}
beforeEach(async function () {
// we create our own sessionStoreBackend so that we can use it for
// another TestClient.
sessionStoreBackend = new testUtils.MockStorageApi();
aliceTestClient = await createTestClient();
});
afterEach(function () {
return aliceTestClient.stop();
});
it("Alice shouldn't do a second /query for non-e2e-capable devices", function () {
aliceTestClient.expectKeyQuery({
device_keys: { "@alice:localhost": {} },
failures: {},
});
return aliceTestClient
.start()
.then(function () {
const syncResponse = getSyncResponse(["@bob:xyz"]);
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse);
return aliceTestClient.flushSync();
})
.then(function () {
logger.log("Forcing alice to download our device keys");
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, {
device_keys: {
"@bob:xyz": {},
},
});
return Promise.all([
aliceTestClient.client.downloadKeys(["@bob:xyz"]),
aliceTestClient.httpBackend.flush("/keys/query", 1),
]);
})
.then(function () {
logger.log("Telling alice to send a megolm message");
aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, {
event_id: "$event_id",
});
return Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, "test"),
// the crypto stuff can take a while, so give the requests a whole second.
aliceTestClient.httpBackend.flushAllExpected({
timeout: 1000,
}),
]);
});
});
it.skip("We should not get confused by out-of-order device query responses", () => {
// https://github.com/vector-im/element-web/issues/3126
aliceTestClient.expectKeyQuery({
device_keys: { "@alice:localhost": {} },
failures: {},
});
return aliceTestClient
.start()
.then(() => {
aliceTestClient.httpBackend
.when("GET", "/sync")
.respond(200, getSyncResponse(["@bob:xyz", "@chris:abc"]));
return aliceTestClient.flushSync();
})
.then(() => {
// to make sure the initial device queries are flushed out, we
// attempt to send a message.
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, {
device_keys: {
"@bob:xyz": {},
"@chris:abc": {},
},
});
aliceTestClient.httpBackend.when("PUT", "/send/").respond(200, { event_id: "$event1" });
return Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, "test"),
aliceTestClient.httpBackend
.flush("/keys/query", 1)
.then(() => aliceTestClient.httpBackend.flush("/send/", 1)),
aliceTestClient.client.crypto!.deviceList.saveIfDirty(),
]);
})
.then(() => {
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
expect(data!.syncToken).toEqual(1);
});
// invalidate bob's and chris's device lists in separate syncs
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: "2",
device_lists: {
changed: ["@bob:xyz"],
},
});
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: "3",
device_lists: {
changed: ["@chris:abc"],
},
});
// flush both syncs
return aliceTestClient.flushSync().then(() => {
return aliceTestClient.flushSync();
});
})
.then(() => {
// check that we don't yet have a request for chris's devices.
aliceTestClient.httpBackend
.when("POST", "/keys/query", {
device_keys: {
"@chris:abc": {},
},
token: "3",
})
.respond(200, {
device_keys: { "@chris:abc": {} },
});
return aliceTestClient.httpBackend.flush("/keys/query", 1);
})
.then((flushed) => {
expect(flushed).toEqual(0);
return aliceTestClient.client.crypto!.deviceList.saveIfDirty();
})
.then(() => {
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
if (bobStat != 1 && bobStat != 2) {
throw new Error("Unexpected status for bob: wanted 1 or 2, got " + bobStat);
}
const chrisStat = data!.trackingStatus["@chris:abc"];
if (chrisStat != 1 && chrisStat != 2) {
throw new Error("Unexpected status for chris: wanted 1 or 2, got " + chrisStat);
}
});
// now add an expectation for a query for bob's devices, and let
// it complete.
aliceTestClient.httpBackend
.when("POST", "/keys/query", {
device_keys: {
"@bob:xyz": {},
},
token: "2",
})
.respond(200, {
device_keys: { "@bob:xyz": {} },
});
return aliceTestClient.httpBackend.flush("/keys/query", 1);
})
.then((flushed) => {
expect(flushed).toEqual(1);
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(["@bob:xyz"]);
})
.then(() => {
return aliceTestClient.client.crypto!.deviceList.saveIfDirty();
})
.then(() => {
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
expect(bobStat).toEqual(3);
const chrisStat = data!.trackingStatus["@chris:abc"];
if (chrisStat != 1 && chrisStat != 2) {
throw new Error("Unexpected status for chris: wanted 1 or 2, got " + bobStat);
}
});
// now let the query for chris's devices complete.
return aliceTestClient.httpBackend.flush("/keys/query", 1);
})
.then((flushed) => {
expect(flushed).toEqual(1);
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(["@chris:abc"]);
})
.then(() => {
return aliceTestClient.client.crypto!.deviceList.saveIfDirty();
})
.then(() => {
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
const chrisStat = data!.trackingStatus["@bob:xyz"];
expect(bobStat).toEqual(3);
expect(chrisStat).toEqual(3);
expect(data!.syncToken).toEqual(3);
});
});
});
// https://github.com/vector-im/element-web/issues/4983
describe("Alice should know she has stale device lists", () => {
beforeEach(async function () {
await aliceTestClient.start();
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse(["@bob:xyz"]));
await aliceTestClient.flushSync();
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, {
device_keys: {
"@bob:xyz": {},
},
});
await aliceTestClient.httpBackend.flush("/keys/query", 1);
await aliceTestClient.client.crypto!.deviceList.saveIfDirty();
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
// Alice should be tracking bob's device list
expect(bobStat).toBeGreaterThan(0);
});
});
it("when Bob leaves", async function () {
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
device_lists: {
left: ["@bob:xyz"],
},
rooms: {
join: {
[ROOM_ID]: {
timeline: {
events: [
testUtils.mkMembership({
mship: KnownMembership.Leave,
sender: "@bob:xyz",
}),
],
},
},
},
},
});
await aliceTestClient.flushSync();
await aliceTestClient.client.crypto!.deviceList.saveIfDirty();
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
// Alice should have marked bob's device list as untracked
expect(bobStat).toEqual(0);
});
});
it("when Alice leaves", async function () {
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
device_lists: {
left: ["@bob:xyz"],
},
rooms: {
leave: {
[ROOM_ID]: {
timeline: {
events: [
testUtils.mkMembership({
mship: KnownMembership.Leave,
sender: "@bob:xyz",
}),
],
},
},
},
},
});
await aliceTestClient.flushSync();
await aliceTestClient.client.crypto!.deviceList.saveIfDirty();
// @ts-ignore accessing a protected field
aliceTestClient.client.cryptoStore!.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
// Alice should have marked bob's device list as untracked
expect(bobStat).toEqual(0);
});
});
it("when Bob leaves whilst Alice is offline", async function () {
aliceTestClient.stop();
const anotherTestClient = await createTestClient();
try {
await anotherTestClient.start();
anotherTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([]));
await anotherTestClient.flushSync();
await anotherTestClient.client?.crypto?.deviceList?.saveIfDirty();
// @ts-ignore accessing private property
anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data!.trackingStatus["@bob:xyz"];
// Alice should have marked bob's device list as untracked
expect(bobStat).toEqual(0);
});
} finally {
anotherTestClient.stop();
}
});
});
});

View File

@ -13,11 +13,10 @@ 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 { type Mocked } from "jest-mock";
import type HttpBackend from "matrix-mock-request";
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED, type IStoredClientOpts, MatrixClient } from "../../src/client";
import { type IStoredClientOpts, MatrixClient } from "../../src/client";
import { MatrixEvent } from "../../src/models/event";
import {
Filter,
@ -34,7 +33,6 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { type IFilterDefinition } from "../../src/filter";
import { type ISearchResults } from "../../src/@types/search";
import { type IStore } from "../../src/store";
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { SetPresence } from "../../src/sync";
import { KnownMembership } from "../../src/@types/membership";
@ -644,126 +642,6 @@ describe("MatrixClient", function () {
});
});
describe("downloadKeys", function () {
if (!CRYPTO_ENABLED) {
return;
}
beforeEach(function () {
// running initLegacyCrypto should trigger a key upload
httpBackend.when("POST", "/keys/upload").respond(200, {});
return Promise.all([client.initLegacyCrypto(), httpBackend.flush("/keys/upload", 1)]);
});
afterEach(() => {
client.stopClient();
});
it("should do an HTTP request and then store the keys", function () {
const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78";
// ed25519key = client.getDeviceEd25519Key();
const borisKeys = {
dev1: {
algorithms: ["1"],
device_id: "dev1",
keys: { "ed25519:dev1": ed25519key },
signatures: {
boris: {
"ed25519:dev1":
"RAhmbNDq1efK3hCpBzZDsKoGSsrHUxb25NW5/WbEV9R" +
"JVwLdP032mg5QsKt/pBDUGtggBcnk43n3nBWlA88WAw",
},
},
unsigned: { abc: "def" },
user_id: "boris",
},
};
const chazKeys = {
dev2: {
algorithms: ["2"],
device_id: "dev2",
keys: { "ed25519:dev2": ed25519key },
signatures: {
chaz: {
"ed25519:dev2":
"FwslH/Q7EYSb7swDJbNB5PSzcbEO1xRRBF1riuijqvL" +
"EkrK9/XVN8jl4h7thGuRITQ01siBQnNmMK9t45QfcCQ",
},
},
unsigned: { ghi: "def" },
user_id: "chaz",
},
};
/*
function sign(o) {
var anotherjson = require('another-json');
var b = JSON.parse(JSON.stringify(o));
delete(b.signatures);
delete(b.unsigned);
return client.crypto.olmDevice.sign(anotherjson.stringify(b));
};
logger.log("Ed25519: " + ed25519key);
logger.log("boris:", sign(borisKeys.dev1));
logger.log("chaz:", sign(chazKeys.dev2));
*/
httpBackend
.when("POST", "/keys/query")
.check(function (req) {
expect(req.data).toEqual({
device_keys: {
boris: [],
chaz: [],
},
});
})
.respond(200, {
device_keys: {
boris: borisKeys,
chaz: chazKeys,
},
});
const prom = client.downloadKeys(["boris", "chaz"]).then(function (res) {
assertObjectContains(res.get("boris")!.get("dev1")!, {
verified: 0, // DeviceVerification.UNVERIFIED
keys: { "ed25519:dev1": ed25519key },
algorithms: ["1"],
unsigned: { abc: "def" },
});
assertObjectContains(res.get("chaz")!.get("dev2")!, {
verified: 0, // DeviceVerification.UNVERIFIED
keys: { "ed25519:dev2": ed25519key },
algorithms: ["2"],
unsigned: { ghi: "def" },
});
});
httpBackend.flush("");
return prom;
});
});
describe("deleteDevice", function () {
const auth = { identifier: 1 };
it("should pass through an auth dict", function () {
httpBackend
.when("DELETE", "/_matrix/client/v3/devices/my_device")
.check(function (req) {
expect(req.data).toEqual({ auth: auth });
})
.respond(200);
const prom = client.deleteDevice("my_device", auth);
httpBackend.flush("");
return prom;
});
});
describe("partitionThreadedEvents", function () {
let room: Room;
beforeEach(() => {
@ -1628,49 +1506,6 @@ describe("MatrixClient", function () {
});
});
describe("uploadKeys", () => {
// uploadKeys() is a no-op nowadays, so there's not much to test here.
it("should complete successfully", async () => {
await client.uploadKeys();
});
});
describe("getCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
getTrustCrossSignedDevices: jest.fn().mockReturnValue(true),
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockBackend;
expect(client.getCryptoTrustCrossSignedDevices()).toBe(true);
mockBackend.getTrustCrossSignedDevices.mockReturnValue(false);
expect(client.getCryptoTrustCrossSignedDevices()).toBe(false);
});
});
describe("setCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
setTrustCrossSignedDevices: jest.fn(),
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockBackend;
client.setCryptoTrustCrossSignedDevices(true);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true);
client.setCryptoTrustCrossSignedDevices(false);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false);
});
});
describe("setSyncPresence", () => {
it("should pass calls through to the underlying sync api", () => {
const setPresence = jest.fn();
@ -2197,11 +2032,3 @@ const buildEventCreate = () =>
type: "m.room.create",
unsigned: { age: 80126105 },
});
function assertObjectContains(obj: Record<string, any>, expected: any): void {
for (const k in expected) {
if (expected.hasOwnProperty(k)) {
expect(obj[k]).toEqual(expected[k]);
}
}
}

View File

@ -26,7 +26,6 @@ import {
UNSTABLE_MSC2716_MARKER,
type MatrixClient,
ClientEvent,
IndexedDBCryptoStore,
type ISyncResponse,
type IRoomEvent,
type IJoinedRoom,
@ -2570,9 +2569,8 @@ describe("MatrixClient syncing (IndexedDB version)", () => {
};
it("should emit ClientEvent.Room when invited while using indexeddb crypto store", async () => {
const idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
cryptoStore: new IndexedDBCryptoStore(globalThis.indexedDB, "tests"),
});
// rust crypto uses by default indexeddb
const idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
const idbHttpBackend = idbTestClient.httpBackend;
const idbClient = idbTestClient.client;
idbHttpBackend.when("GET", "/versions").respond(200, {});

View File

@ -558,18 +558,6 @@ export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
...extra,
});
/**
* a list of the supported crypto implementations, each with a callback to initialise that implementation
* for the given client
*/
export const CRYPTO_BACKENDS: Record<string, InitCrypto> = {};
export type InitCrypto = (_: MatrixClient) => Promise<void>;
CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto();
if (globalThis.Olm) {
CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initLegacyCrypto();
}
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
/**

File diff suppressed because it is too large Load Diff

View File

@ -1,243 +0,0 @@
/*
Copyright 2020 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 "../../olm-loader";
import { CrossSigningInfo, createCryptoStoreCacheCallbacks } from "../../../src/crypto/CrossSigning";
import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import "fake-indexeddb/auto";
import "jest-localstorage-mock";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
import { logger } from "../../../src/logger";
const userId = "@alice:example.com";
// Private key for tests only
const testKey = new Uint8Array([
0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05,
0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d,
]);
const types = [
{ type: "master", shouldCache: true },
{ type: "self_signing", shouldCache: true },
{ type: "user_signing", shouldCache: true },
{ type: "invalid", shouldCache: false },
];
const badKey = Uint8Array.from(testKey);
badKey[0] ^= 1;
const masterKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk";
describe("CrossSigningInfo.getCrossSigningKey", function () {
if (!globalThis.Olm) {
logger.warn("Not running megolm backup unit tests: libolm not present");
return;
}
beforeAll(function () {
return globalThis.Olm.init();
});
it("should throw if no callback is provided", async () => {
const info = new CrossSigningInfo(userId);
await expect(info.getCrossSigningKey("master")).rejects.toThrow();
});
it.each(types)("should throw if the callback returns falsey", async ({ type, shouldCache }) => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: async () => false as unknown as Uint8Array,
});
await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey");
});
it("should throw if the expected key doesn't come back", async () => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array,
});
await expect(info.getCrossSigningKey("master", "")).rejects.toThrow();
});
it("should return a key from its callback", async () => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: async () => testKey,
});
const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
// check that the pkSigning object corresponds to the pubKey
const signature = pkSigning.sign("message");
const util = new globalThis.Olm.Utility();
try {
util.ed25519_verify(pubKey, "message", signature);
} finally {
util.free();
}
});
it.each(types)(
"should request a key from the cache callback (if set)" + " and does not call app if one is found" + " %o",
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) {
return Promise.reject(new Error("Regular callback called"));
} else {
return Promise.resolve(testKey);
}
});
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey);
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache });
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(getCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
if (shouldCache) {
// eslint-disable-next-line jest/no-conditional-expect
expect(getCrossSigningKeyCache).toHaveBeenLastCalledWith(type, expect.any(String));
}
},
);
it.each(types)("should store a key with the cache callback (if set)", async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache });
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(storeCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
if (shouldCache) {
// eslint-disable-next-line jest/no-conditional-expect
expect(storeCrossSigningKeyCache).toHaveBeenLastCalledWith(type, testKey);
}
});
it.each(types)("does not store a bad key to the cache", async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(badKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache });
await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow();
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0);
});
it.each(types)("does not store a value to the cache if it came from the cache", async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) {
return Promise.reject(new Error("Regular callback called"));
} else {
return Promise.resolve(testKey);
}
});
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(new Error("Tried to store a value from cache"));
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
});
it.each(types)(
"requests a key from the cache callback (if set) and then calls app" + " if one is not found",
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const storeCrossSigningKeyCache = jest.fn();
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(getCrossSigningKey.mock.calls.length).toBe(1);
expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
/* Also expect that the cache gets updated */
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
},
);
it.each(types)(
"requests a key from the cache callback (if set) and then" + " calls app if that key doesn't match",
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(badKey);
const storeCrossSigningKeyCache = jest.fn();
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(getCrossSigningKey.mock.calls.length).toBe(1);
expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
/* Also expect that the cache gets updated */
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
},
);
});
/*
* Note that MemoryStore is weird. It's only used for testing - as far as I can tell,
* it's not possible to get one in normal execution unless you hack as we do here.
*/
describe.each([
["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(globalThis.indexedDB, "tests")],
["LocalStorageCryptoStore", () => new IndexedDBCryptoStore(undefined!, "tests")],
[
"MemoryCryptoStore",
() => {
const store = new IndexedDBCryptoStore(undefined!, "tests");
// @ts-ignore set private properties
store._backend = new MemoryCryptoStore();
// @ts-ignore
store._backendPromise = Promise.resolve(store._backend);
return store;
},
],
])("CrossSigning > createCryptoStoreCacheCallbacks [%s]", function (name, dbFactory) {
let store: IndexedDBCryptoStore;
beforeAll(() => {
store = dbFactory();
});
beforeEach(async () => {
await store.deleteAllData();
});
it("should cache data to the store and retrieve it", async () => {
await store.startup();
const olmDevice = new OlmDevice(store);
const { getCrossSigningKeyCache, storeCrossSigningKeyCache } = createCryptoStoreCacheCallbacks(
store,
olmDevice,
);
await storeCrossSigningKeyCache!("self_signing", testKey);
// If we've not saved anything, don't expect anything
// Definitely don't accidentally return the wrong key for the type
const nokey = await getCrossSigningKeyCache!("self", "");
expect(nokey).toBeNull();
const key = await getCrossSigningKeyCache!("self_signing", "");
expect(new Uint8Array(key!)).toEqual(testKey);
});
});

View File

@ -1,215 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 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 { logger } from "../../../src/logger";
import * as utils from "../../../src/utils";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import { DeviceList } from "../../../src/crypto/DeviceList";
import { type IDownloadKeyResult, type MatrixClient } from "../../../src";
import { type OlmDevice } from "../../../src/crypto/OlmDevice";
import { type CryptoStore } from "../../../src/crypto/store/base";
const signedDeviceList: IDownloadKeyResult = {
failures: {},
device_keys: {
"@test1:sw1v.org": {
HGKAWHRVJQ: {
signatures: {
"@test1:sw1v.org": {
"ed25519:HGKAWHRVJQ":
"8PB450fxKDn5s8IiRZ2N2t6MiueQYVRLHFEzqIi1eLdxx1w" +
"XEPC1/1Uz9T4gwnKlMVAKkhB5hXQA/3kjaeLABw",
},
},
user_id: "@test1:sw1v.org",
keys: {
"ed25519:HGKAWHRVJQ": "0gI/T6C+mn1pjtvnnW2yB2l1IIBb/5ULlBXi/LXFSEQ",
"curve25519:HGKAWHRVJQ": "mbIZED1dBsgIgkgzxDpxKkJmsr4hiWlGzQTvUnQe3RY",
},
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "HGKAWHRVJQ",
unsigned: {
device_display_name: "",
},
},
},
},
};
const signedDeviceList2: IDownloadKeyResult = {
failures: {},
device_keys: {
"@test2:sw1v.org": {
QJVRHWAKGH: {
signatures: {
"@test2:sw1v.org": {
"ed25519:QJVRHWAKGH":
"w1xxdLe1iIqzEFHLRVYQeuiM6t2N2ZRiI8s5nDKxf054BP8" +
"1CPEX/AQXh5BhkKAVMlKnwg4T9zU1/wBALeajk3",
},
},
user_id: "@test2:sw1v.org",
keys: {
"ed25519:QJVRHWAKGH": "Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL",
"curve25519:QJVRHWAKGH": "YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm",
},
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "QJVRHWAKGH",
unsigned: {
device_display_name: "",
},
},
},
},
};
describe("DeviceList", function () {
let downloadSpy: jest.Mock;
let cryptoStore: CryptoStore;
let deviceLists: DeviceList[] = [];
beforeEach(function () {
deviceLists = [];
downloadSpy = jest.fn();
cryptoStore = new MemoryCryptoStore();
});
afterEach(function () {
for (const dl of deviceLists) {
dl.stop();
}
});
function createTestDeviceList(keyDownloadChunkSize = 250) {
const baseApis = {
downloadKeysForUsers: downloadSpy,
getUserId: () => "@test1:sw1v.org",
deviceId: "HGKAWHRVJQ",
} as unknown as MatrixClient;
const mockOlm = {
verifySignature: function (key: string, message: string, signature: string) {},
} as unknown as OlmDevice;
const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize);
deviceLists.push(dl);
return dl;
}
it("should successfully download and store device keys", function () {
const dl = createTestDeviceList();
dl.startTrackingDeviceList("@test1:sw1v.org");
const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer1.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {});
queryDefer1.resolve(utils.deepCopy(signedDeviceList));
return prom1.then(() => {
const storedKeys = dl.getRawStoredDevicesForUser("@test1:sw1v.org");
expect(Object.keys(storedKeys)).toEqual(["HGKAWHRVJQ"]);
dl.stop();
});
});
it("should have an outdated devicelist on an invalidation while an update is in progress", async function () {
const dl = createTestDeviceList();
dl.startTrackingDeviceList("@test1:sw1v.org");
const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer1.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {});
downloadSpy.mockReset();
// outdated notif arrives while the request is in flight.
const queryDefer2 = utils.defer();
downloadSpy.mockReturnValue(queryDefer2.promise);
dl.invalidateUserDeviceList("@test1:sw1v.org");
dl.refreshOutdatedDeviceLists();
await dl
.saveIfDirty()
.then(() => {
// the first request completes
queryDefer1.resolve({
failures: {},
device_keys: {
"@test1:sw1v.org": {},
},
});
return prom1;
})
.then(async () => {
// uh-oh; user restarts before second request completes. The new instance
// should know we never got a complete device list.
logger.log("Creating new devicelist to simulate app reload");
downloadSpy.mockReset();
const dl2 = createTestDeviceList();
await dl2.load();
const queryDefer3 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer3.promise);
const prom3 = dl2.refreshOutdatedDeviceLists();
expect(downloadSpy).toHaveBeenCalledWith(["@test1:sw1v.org"], {});
dl2.stop();
queryDefer3.resolve(utils.deepCopy(signedDeviceList));
// allow promise chain to complete
return prom3;
})
.then(() => {
const storedKeys = dl.getRawStoredDevicesForUser("@test1:sw1v.org");
expect(Object.keys(storedKeys)).toEqual(["HGKAWHRVJQ"]);
dl.stop();
});
});
it("should download device keys in batches", function () {
const dl = createTestDeviceList(1);
dl.startTrackingDeviceList("@test1:sw1v.org");
dl.startTrackingDeviceList("@test2:sw1v.org");
const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValueOnce(queryDefer1.promise);
const queryDefer2 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValueOnce(queryDefer2.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
expect(downloadSpy).toHaveBeenCalledTimes(2);
expect(downloadSpy).toHaveBeenNthCalledWith(1, ["@test1:sw1v.org"], {});
expect(downloadSpy).toHaveBeenNthCalledWith(2, ["@test2:sw1v.org"], {});
queryDefer1.resolve(utils.deepCopy(signedDeviceList));
queryDefer2.resolve(utils.deepCopy(signedDeviceList2));
return prom1.then(() => {
const storedKeys1 = dl.getRawStoredDevicesForUser("@test1:sw1v.org");
expect(Object.keys(storedKeys1)).toEqual(["HGKAWHRVJQ"]);
const storedKeys2 = dl.getRawStoredDevicesForUser("@test2:sw1v.org");
expect(Object.keys(storedKeys2)).toEqual(["QJVRHWAKGH"]);
dl.stop();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,257 +0,0 @@
/*
Copyright 2018,2019 New Vector Ltd
Copyright 2019, 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 { type MockedObject } from "jest-mock";
import "../../../olm-loader";
import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store";
import { logger } from "../../../../src/logger";
import { OlmDevice } from "../../../../src/crypto/OlmDevice";
import * as olmlib from "../../../../src/crypto/olmlib";
import { DeviceInfo } from "../../../../src/crypto/deviceinfo";
import { type MatrixClient } from "../../../../src";
function makeOlmDevice() {
const cryptoStore = new MemoryCryptoStore();
const olmDevice = new OlmDevice(cryptoStore);
return olmDevice;
}
async function setupSession(initiator: OlmDevice, opponent: OlmDevice) {
await opponent.generateOneTimeKeys(1);
const keys = await opponent.getOneTimeKeys();
const firstKey = Object.values(keys["curve25519"])[0];
const sid = await initiator.createOutboundSession(opponent.deviceCurve25519Key!, firstKey);
return sid;
}
function alwaysSucceed<T>(promise: Promise<T>): Promise<T | void> {
// swallow any exception thrown by a promise, so that
// Promise.all doesn't abort
return promise.catch(() => {});
}
describe("OlmDevice", function () {
if (!globalThis.Olm) {
logger.warn("Not running megolm unit tests: libolm not present");
return;
}
beforeAll(function () {
return globalThis.Olm.init();
});
let aliceOlmDevice: OlmDevice;
let bobOlmDevice: OlmDevice;
beforeEach(async function () {
aliceOlmDevice = makeOlmDevice();
bobOlmDevice = makeOlmDevice();
await aliceOlmDevice.init();
await bobOlmDevice.init();
});
describe("olm", function () {
it("can decrypt messages", async function () {
const sid = await setupSession(aliceOlmDevice, bobOlmDevice);
const ciphertext = (await aliceOlmDevice.encryptMessage(
bobOlmDevice.deviceCurve25519Key!,
sid,
"The olm or proteus is an aquatic salamander in the family Proteidae",
)) as any; // OlmDevice.encryptMessage has incorrect return type
const result = await bobOlmDevice.createInboundSession(
aliceOlmDevice.deviceCurve25519Key!,
ciphertext.type,
ciphertext.body,
);
expect(result.payload).toEqual("The olm or proteus is an aquatic salamander in the family Proteidae");
});
it("exports picked account and olm sessions", async function () {
const sessionId = await setupSession(aliceOlmDevice, bobOlmDevice);
const exported = await bobOlmDevice.export();
// At this moment only Alice (the “initiator” in setupSession) has a session
expect(exported.sessions).toEqual([]);
const MESSAGE = "The olm or proteus is an aquatic salamander" + " in the family Proteidae";
const ciphertext = (await aliceOlmDevice.encryptMessage(
bobOlmDevice.deviceCurve25519Key!,
sessionId,
MESSAGE,
)) as any; // OlmDevice.encryptMessage has incorrect return type
const bobRecreatedOlmDevice = makeOlmDevice();
bobRecreatedOlmDevice.init({ fromExportedDevice: exported });
const decrypted = await bobRecreatedOlmDevice.createInboundSession(
aliceOlmDevice.deviceCurve25519Key!,
ciphertext.type,
ciphertext.body,
);
expect(decrypted.payload).toEqual(MESSAGE);
const exportedAgain = await bobRecreatedOlmDevice.export();
// this time we expect Bob to have a session to export
expect(exportedAgain.sessions).toHaveLength(1);
const MESSAGE_2 = "In contrast to most amphibians," + " the olm is entirely aquatic";
const ciphertext2 = (await aliceOlmDevice.encryptMessage(
bobOlmDevice.deviceCurve25519Key!,
sessionId,
MESSAGE_2,
)) as any; // OlmDevice.encryptMessage has incorrect return type
const bobRecreatedAgainOlmDevice = makeOlmDevice();
bobRecreatedAgainOlmDevice.init({ fromExportedDevice: exportedAgain });
// Note: "decrypted_2" does not have the same structure as "decrypted"
const decrypted2 = await bobRecreatedAgainOlmDevice.decryptMessage(
aliceOlmDevice.deviceCurve25519Key!,
decrypted.session_id,
ciphertext2.type,
ciphertext2.body,
);
expect(decrypted2).toEqual(MESSAGE_2);
});
it("creates only one session at a time", async function () {
// if we call ensureOlmSessionsForDevices multiple times, it should
// only try to create one session at a time, even if the server is
// slow
let count = 0;
const baseApis = {
claimOneTimeKeys: () => {
// simulate a very slow server (.5 seconds to respond)
count++;
return new Promise((resolve, reject) => {
setTimeout(reject, 500);
});
},
} as unknown as MockedObject<MatrixClient>;
const devicesByUser = new Map([
[
"@bob:example.com",
[
DeviceInfo.fromStorage(
{
keys: {
"curve25519:ABCDEFG": "akey",
},
},
"ABCDEFG",
),
],
],
]);
// start two tasks that try to ensure that there's an olm session
const promises = Promise.all([
alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUser)),
alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUser)),
]);
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
// after .2s, both tasks should have started, but one should be
// waiting on the other before trying to create a session, so
// claimOneTimeKeys should have only been called once
expect(count).toBe(1);
await promises;
// after waiting for both tasks to complete, the first task should
// have failed, so the second task should have tried to create a
// new session and will have called claimOneTimeKeys
expect(count).toBe(2);
});
it("avoids deadlocks when two tasks are ensuring the same devices", async function () {
// This test checks whether `ensureOlmSessionsForDevices` properly
// handles multiple tasks in flight ensuring some set of devices in
// common without deadlocks.
let claimRequestCount = 0;
const baseApis = {
claimOneTimeKeys: () => {
// simulate a very slow server (.5 seconds to respond)
claimRequestCount++;
return new Promise((resolve, reject) => {
setTimeout(reject, 500);
});
},
} as unknown as MockedObject<MatrixClient>;
const deviceBobA = DeviceInfo.fromStorage(
{
keys: {
"curve25519:BOB-A": "akey",
},
},
"BOB-A",
);
const deviceBobB = DeviceInfo.fromStorage(
{
keys: {
"curve25519:BOB-B": "bkey",
},
},
"BOB-B",
);
// There's no required ordering of devices per user, so here we
// create two different orderings so that each task reserves a
// device the other task needs before continuing.
const devicesByUserAB = new Map([["@bob:example.com", [deviceBobA, deviceBobB]]]);
const devicesByUserBA = new Map([["@bob:example.com", [deviceBobB, deviceBobA]]]);
const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserAB));
// After a single tick through the first task, it should have
// claimed ownership of all devices to avoid deadlocking others.
expect(Object.keys(aliceOlmDevice.sessionsInProgress).length).toBe(2);
const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserBA));
// The second task should not have changed the ownership count, as
// it's waiting on the first task.
expect(Object.keys(aliceOlmDevice.sessionsInProgress).length).toBe(2);
// Track the tasks, but don't await them yet.
const promises = Promise.all([task1, task2]);
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
// After .2s, the first task should have made an initial claim request.
expect(claimRequestCount).toBe(1);
await promises;
// After waiting for both tasks to complete, the first task should
// have failed, so the second task should have tried to create a
// new session and will have called claimOneTimeKeys
expect(claimRequestCount).toBe(2);
});
});
});

View File

@ -1,791 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 "../../olm-loader";
import { logger } from "../../../src/logger";
import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixClient } from "../../../src/client";
import { MatrixEvent } from "../../../src/models/event";
import * as algorithms from "../../../src/crypto/algorithms";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import * as testUtils from "../../test-utils/test-utils";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
import { Crypto } from "../../../src/crypto";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BackupManager } from "../../../src/crypto/backup";
import { StubStore } from "../../../src/store/stub";
import { IndexedDBCryptoStore, type MatrixScheduler } from "../../../src";
import { type CryptoStore } from "../../../src/crypto/store/base";
import { type MegolmDecryption as MegolmDecryptionClass } from "../../../src/crypto/algorithms/megolm";
import { type IKeyBackupInfo } from "../../../src/crypto/keybackup";
const Olm = globalThis.Olm;
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
const ROOM_ID = "!ROOM:ID";
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
const ENCRYPTED_EVENT = new MatrixEvent({
type: "m.room.encrypted",
room_id: "!ROOM:ID",
content: {
algorithm: "m.megolm.v1.aes-sha2",
sender_key: "SENDER_CURVE25519",
session_id: SESSION_ID,
ciphertext:
"AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N" +
"CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl" +
"mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs",
},
event_id: "$event1",
origin_server_ts: 1507753886000,
});
const CURVE25519_KEY_BACKUP_DATA = {
first_message_index: 0,
forwarded_count: 0,
is_verified: false,
session_data: {
ciphertext:
"2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw" +
"6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ" +
"Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9" +
"SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy" +
"Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF" +
"ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV" +
"4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv" +
"C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe" +
"Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf" +
"QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy" +
"iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg",
mac: "5lxYBHQU80M",
ephemeral: "/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14",
},
};
const AES256_KEY_BACKUP_DATA = {
first_message_index: 0,
forwarded_count: 0,
is_verified: false,
session_data: {
iv: "b3Jqqvm5S9QdmXrzssspLQ",
ciphertext:
"GOOASO3E9ThogkG0zMjEduGLM3u9jHZTkS7AvNNbNj3q1znwk4OlaVKXce" +
"7ynofiiYIiS865VlOqrKEEXv96XzRyUpgn68e3WsicwYl96EtjIEh/iY003PG2Qd" +
"EluT899Ax7PydpUHxEktbWckMppYomUR5q8x1KI1SsOQIiJaIGThmIMPANRCFiK0" +
"WQj+q+dnhzx4lt9AFqU5bKov8qKnw2qGYP7/+6RmJ0Kpvs8tG6lrcNDEHtFc2r0r" +
"KKubDypo0Vc8EWSwsAHdKa36ewRavpreOuE8Z9RLfY0QIR1ecXrMqW0CdGFr7H3P" +
"vcjF8sjwvQAavzxEKT1WMGizSMLeKWo2mgZ5cKnwV5HGUAw596JQvKs9laG2U89K" +
"YrT0sH30vi62HKzcBLcDkWkUSNYPz7UiZ1MM0L380UA+1ZOXSOmtBA9xxzzbc8Xd" +
"fRimVgklGdxrxjzuNLYhL2BvVH4oPWonD9j0bvRwE6XkimdbGQA8HB7UmXXjE8WA" +
"RgaDHkfzoA3g3aeQ",
mac: "uR988UYgGL99jrvLLPX3V1ows+UYbktTmMxPAo2kxnU",
},
};
const CURVE25519_BACKUP_INFO = {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const AES256_BACKUP_INFO: IKeyBackupInfo = {
algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2",
version: "1",
auth_data: {} as IKeyBackupInfo["auth_data"],
};
const keys: Record<string, Uint8Array> = {};
function getCrossSigningKey(type: string) {
return Promise.resolve(keys[type]);
}
function saveCrossSigningKeys(k: Record<string, Uint8Array>) {
Object.assign(keys, k);
}
function makeTestScheduler(): MatrixScheduler {
return (["getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction"] as const).reduce(
(r, k) => {
r[k] = jest.fn();
return r;
},
{} as MatrixScheduler,
);
}
function makeTestClient(cryptoStore: CryptoStore) {
const scheduler = makeTestScheduler();
const store = new StubStore();
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
fetchFn: jest.fn(), // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
cryptoStore: cryptoStore,
cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys },
});
// initialising the crypto library will trigger a key upload request, which we can stub out
client.uploadKeysRequest = jest.fn();
return client;
}
describe("MegolmBackup", function () {
if (!globalThis.Olm) {
logger.warn("Not running megolm backup unit tests: libolm not present");
return;
}
beforeAll(function () {
return Olm.init();
});
let olmDevice: OlmDevice;
let mockOlmLib: typeof olmlib;
let mockCrypto: Crypto;
let cryptoStore: CryptoStore;
let megolmDecryption: MegolmDecryptionClass;
beforeEach(async function () {
mockCrypto = testUtils.mock(Crypto, "Crypto");
// @ts-ignore making mock
mockCrypto.backupManager = testUtils.mock(BackupManager, "BackupManager");
mockCrypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO;
cryptoStore = new MemoryCryptoStore();
olmDevice = new OlmDevice(cryptoStore);
// we stub out the olm encryption bits
mockOlmLib = {} as unknown as typeof olmlib;
mockOlmLib.ensureOlmSessionsForDevices = jest.fn();
mockOlmLib.encryptMessageForDevice = jest.fn().mockResolvedValue(undefined);
});
describe("backup", function () {
let mockBaseApis: MatrixClient;
beforeEach(function () {
mockBaseApis = {} as unknown as MatrixClient;
megolmDecryption = new MegolmDecryption({
userId: "@user:id",
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: mockBaseApis,
roomId: ROOM_ID,
}) as MegolmDecryptionClass;
// @ts-ignore private field access
megolmDecryption.olmlib = mockOlmLib;
// clobber the setTimeout function to run 100x faster.
// ideally we would use lolex, but we have no oportunity
// to tick the clock between the first try and the retry.
const realSetTimeout = globalThis.setTimeout;
jest.spyOn(globalThis, "setTimeout").mockImplementation(function (f, n) {
return realSetTimeout(f!, n! / 100);
});
});
afterEach(function () {
jest.spyOn(globalThis, "setTimeout").mockRestore();
});
test("fail if crypto not enabled", async () => {
const client = makeTestClient(cryptoStore);
const data = {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
await expect(client.restoreKeyBackupWithSecretStorage(data)).rejects.toThrow(
"End-to-end encryption disabled",
);
});
test("fail if given backup has no version", async () => {
const client = makeTestClient(cryptoStore);
await client.initLegacyCrypto();
const data = {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
await client.getCrypto()!.storeSessionBackupPrivateKey(key, "1");
await expect(client.restoreKeyBackupWithCache(undefined, undefined, data)).rejects.toThrow(
"Backup version must be defined",
);
});
it("automatically calls the key back up", function () {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// construct a fake decrypted key event via the use of a mocked
// 'crypto' implementation.
const event = new MatrixEvent({
type: "m.room.encrypted",
});
event.getWireType = () => "m.room.encrypted";
event.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
const decryptedData = {
clearEvent: {
type: "m.room_key",
content: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: ROOM_ID,
session_id: groupSession.session_id(),
session_key: groupSession.session_key(),
},
},
senderCurve25519Key: "SENDER_CURVE25519",
claimedEd25519Key: "SENDER_ED25519",
};
mockCrypto.decryptEvent = function () {
return Promise.resolve(decryptedData);
};
mockCrypto.cancelRoomKeyRequest = function () {};
// @ts-ignore readonly field write
mockCrypto.backupManager = {
backupGroupSession: jest.fn(),
};
return event
.attemptDecryption(mockCrypto)
.then(() => {
return megolmDecryption.onRoomKeyEvent(event);
})
.then(() => {
expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled();
});
});
it("sends backups to the server (Curve25519 version)", function () {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: "@user:id",
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
}) as MegolmDecryptionClass;
// @ts-ignore private field access
megolmDecryption.olmlib = mockOlmLib;
return client
.initLegacyCrypto()
.then(() => {
return cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined!,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice.pickleKey),
},
txn,
);
});
})
.then(async () => {
await client.enableKeyBackup({
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
return new Promise<void>((resolve, reject) => {
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(1);
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams?.version).toBe("1");
expect((data as Record<string, any>).rooms[ROOM_ID].sessions).toBeDefined();
expect((data as Record<string, any>).rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
resolve();
return Promise.resolve({});
};
client.crypto!.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);
}).then(() => {
expect(numCalls).toBe(1);
client.stopClient();
});
});
});
it("sends backups to the server (AES-256 version)", function () {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: "@user:id",
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
}) as MegolmDecryptionClass;
// @ts-ignore private field access
megolmDecryption.olmlib = mockOlmLib;
return client
.initLegacyCrypto()
.then(() => {
return client.crypto!.storeSessionBackupPrivateKey(new Uint8Array(32));
})
.then(() => {
return cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined!,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice.pickleKey),
},
txn,
);
});
})
.then(async () => {
await client.enableKeyBackup({
algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2",
version: "1",
auth_data: {
iv: "PsCAtR7gMc4xBd9YS3A9Ow",
mac: "ZSDsTFEZK7QzlauCLMleUcX96GQZZM7UNtk4sripSqQ",
},
});
let numCalls = 0;
return new Promise<void>((resolve, reject) => {
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(1);
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams?.version).toBe("1");
expect((data as Record<string, any>).rooms[ROOM_ID].sessions).toBeDefined();
expect((data as Record<string, any>).rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
resolve();
return Promise.resolve({});
};
client.crypto!.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);
}).then(() => {
expect(numCalls).toBe(1);
client.stopClient();
});
});
});
it("signs backups with the cross-signing master key", async function () {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: "@user:id",
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
}) as MegolmDecryptionClass;
// @ts-ignore private field access
megolmDecryption.olmlib = mockOlmLib;
await client.initLegacyCrypto();
client.uploadDeviceSigningKeys = async function (e) {
return {};
};
client.uploadKeySignatures = async function (e) {
return { failures: {} };
};
await resetCrossSigningKeys(client);
let numCalls = 0;
await Promise.all([
new Promise<void>((resolve, reject) => {
let backupInfo: Record<string, any> | BodyInit | undefined;
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
/* eslint-disable jest/no-conditional-expect */
if (numCalls === 1) {
expect(method).toBe("POST");
expect(path).toBe("/room_keys/version");
try {
// make sure auth_data is signed by the master key
olmlib.pkVerify(
(data as Record<string, any>).auth_data,
client.getCrossSigningId()!,
"@alice:bar",
);
} catch (e) {
reject(e);
return Promise.resolve({});
}
backupInfo = data;
return Promise.resolve({});
} else if (numCalls === 2) {
expect(method).toBe("GET");
expect(path).toBe("/room_keys/version");
resolve();
return Promise.resolve(backupInfo);
} else {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many times"));
return Promise.resolve({});
}
/* eslint-enable jest/no-conditional-expect */
};
}),
client.createKeyBackupVersion({
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
}),
]);
expect(numCalls).toBe(2);
client.stopClient();
});
it("retries when a backup fails", async function () {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const scheduler = makeTestScheduler();
const store = new StubStore();
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
fetchFn: jest.fn(), // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
cryptoStore: cryptoStore,
});
// initialising the crypto library will trigger a key upload request, which we can stub out
client.uploadKeysRequest = jest.fn();
megolmDecryption = new MegolmDecryption({
userId: "@user:id",
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
}) as MegolmDecryptionClass;
// @ts-ignore private field access
megolmDecryption.olmlib = mockOlmLib;
await client.initLegacyCrypto();
await cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined!,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice.pickleKey),
},
txn,
);
});
await client.enableKeyBackup({
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
await new Promise<void>((resolve, reject) => {
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
if (numCalls >= 3) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams?.version).toBe("1");
expect((data as Record<string, any>).rooms[ROOM_ID].sessions).toBeDefined();
expect((data as Record<string, any>).rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
if (numCalls > 1) {
resolve();
return Promise.resolve({});
} else {
return Promise.reject(new Error("this is an expected failure"));
}
};
return client.crypto!.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);
});
expect(numCalls).toBe(2);
client.stopClient();
});
});
describe("restore", function () {
let client: MatrixClient;
beforeEach(function () {
client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: "@user:id",
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
}) as MegolmDecryptionClass;
// @ts-ignore private field access
megolmDecryption.olmlib = mockOlmLib;
return client.initLegacyCrypto();
});
afterEach(function () {
client.stopClient();
});
it("can restore from backup (Curve25519 version)", function () {
client.http.authedRequest = function () {
return Promise.resolve<any>(CURVE25519_KEY_BACKUP_DATA);
};
return client
.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
CURVE25519_BACKUP_INFO,
)
.then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
})
.then((res) => {
expect(res.clearEvent.content).toEqual("testytest");
expect(res.untrusted).toBeTruthy(); // keys from Curve25519 backup are untrusted
});
});
it("can restore from backup (AES-256 version)", function () {
client.http.authedRequest = function () {
return Promise.resolve<any>(AES256_KEY_BACKUP_DATA);
};
return client
.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
AES256_BACKUP_INFO,
)
.then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
})
.then((res) => {
expect(res.clearEvent.content).toEqual("testytest");
expect(res.untrusted).toBeFalsy(); // keys from AES backup are trusted
});
});
it("can restore backup by room (Curve25519 version)", function () {
client.http.authedRequest = function () {
return Promise.resolve<any>({
rooms: {
[ROOM_ID]: {
sessions: {
[SESSION_ID]: CURVE25519_KEY_BACKUP_DATA,
},
},
},
});
};
return client
.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
null!,
null!,
CURVE25519_BACKUP_INFO,
)
.then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
})
.then((res) => {
expect(res.clearEvent.content).toEqual("testytest");
});
});
it("has working cache functions", async function () {
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
await client.crypto!.storeSessionBackupPrivateKey(key);
const result = await client.crypto!.getSessionBackupPrivateKey();
expect(new Uint8Array(result!)).toEqual(key);
});
it("caches session backup keys as it encounters them", async function () {
const cachedNull = await client.crypto!.getSessionBackupPrivateKey();
expect(cachedNull).toBeNull();
client.http.authedRequest = function () {
return Promise.resolve<any>(CURVE25519_KEY_BACKUP_DATA);
};
await new Promise<void>((resolve) => {
client.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
CURVE25519_BACKUP_INFO,
{ cacheCompleteCallback: resolve },
);
});
const cachedKey = await client.crypto!.getSessionBackupPrivateKey();
expect(cachedKey).not.toBeNull();
});
it("fails if an known algorithm is used", async function () {
const BAD_BACKUP_INFO = Object.assign({}, CURVE25519_BACKUP_INFO, {
algorithm: "this.algorithm.does.not.exist",
});
client.http.authedRequest = function () {
return Promise.resolve<any>(CURVE25519_KEY_BACKUP_DATA);
};
await expect(
client.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
BAD_BACKUP_INFO,
),
).rejects.toThrow();
});
});
describe("flagAllGroupSessionsForBackup", () => {
it("should return number of sesions needing backup", async () => {
const scheduler = makeTestScheduler();
const store = new StubStore();
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
fetchFn: jest.fn(), // NOP
store,
scheduler,
userId: "@alice:bar",
deviceId: "device",
cryptoStore,
});
// initialising the crypto library will trigger a key upload request, which we can stub out
client.uploadKeysRequest = jest.fn();
await client.initLegacyCrypto();
cryptoStore.countSessionsNeedingBackup = jest.fn().mockReturnValue(6);
await expect(client.flagAllGroupSessionsForBackup()).resolves.toBe(6);
client.stopClient();
});
});
describe("getKeyBackupInfo", () => {
it("should return throw an `Not implemented`", async () => {
const client = makeTestClient(cryptoStore);
await client.initLegacyCrypto();
await expect(client.getCrypto()?.getKeyBackupInfo()).rejects.toThrow("Not implemented");
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +0,0 @@
import { type IRecoveryKey } from "../../../src/crypto/api";
import { type CrossSigningLevel } from "../../../src/crypto/CrossSigning";
import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store";
import { type MatrixClient } from "../../../src";
import { CryptoEvent } from "../../../src/crypto";
// needs to be phased out and replaced with bootstrapSecretStorage,
// but that is doing too much extra stuff for it to be an easy transition.
export async function resetCrossSigningKeys(
client: MatrixClient,
{ level }: { level?: CrossSigningLevel } = {},
): Promise<void> {
const crypto = client.crypto!;
const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys);
try {
await crypto.crossSigningInfo.resetKeys(level);
await crypto.signObject(crypto.crossSigningInfo.keys.master);
// write a copy locally so we know these are trusted keys
await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
crypto.cryptoStore.storeCrossSigningKeys(txn, crypto.crossSigningInfo.keys);
});
} catch (e) {
// If anything failed here, revert the keys so we know to try again from the start
// next time.
crypto.crossSigningInfo.keys = oldKeys;
throw e;
}
crypto.emit(CryptoEvent.KeysChanged, {});
// @ts-ignore
await crypto.afterCrossSigningLocalKeyChange();
}
export async function createSecretStorageKey(): Promise<IRecoveryKey> {
const decryption = new globalThis.Olm.PkDecryption();
decryption.generate_key();
const storagePrivateKey = decryption.get_private_key();
decryption.free();
return {
privateKey: storagePrivateKey,
};
}

View File

@ -1,138 +0,0 @@
/*
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 "../../olm-loader";
import { TestClient } from "../../TestClient";
import { logger } from "../../../src/logger";
import { DEHYDRATION_ALGORITHM } from "../../../src/crypto/dehydration";
const Olm = globalThis.Olm;
describe("Dehydration", () => {
if (!globalThis.Olm) {
logger.warn("Not running dehydration unit tests: libolm not present");
return;
}
beforeAll(function () {
return globalThis.Olm.init();
});
it("should rehydrate a dehydrated device", async () => {
const key = new Uint8Array([1, 2, 3]);
const alice = new TestClient("@alice:example.com", "Osborne2", undefined, undefined, {
cryptoCallbacks: {
getDehydrationKey: async (t) => key,
},
});
const dehydratedDevice = new Olm.Account();
dehydratedDevice.create();
alice.httpBackend.when("GET", "/dehydrated_device").respond(200, {
device_id: "ABCDEFG",
device_data: {
algorithm: DEHYDRATION_ALGORITHM,
account: dehydratedDevice.pickle(new Uint8Array(key)),
},
});
alice.httpBackend.when("POST", "/dehydrated_device/claim").respond(200, {
success: true,
});
expect((await Promise.all([alice.client.rehydrateDevice(), alice.httpBackend.flushAllExpected()]))[0]).toEqual(
"ABCDEFG",
);
expect(alice.client.getDeviceId()).toEqual("ABCDEFG");
});
it("should dehydrate a device", async () => {
const key = new Uint8Array([1, 2, 3]);
const alice = new TestClient("@alice:example.com", "Osborne2", undefined, undefined, {
cryptoCallbacks: {
getDehydrationKey: async (t) => key,
},
});
await alice.client.initLegacyCrypto();
alice.httpBackend.when("GET", "/room_keys/version").respond(404, {
errcode: "M_NOT_FOUND",
});
let pickledAccount = "";
alice.httpBackend
.when("PUT", "/dehydrated_device")
.check((req) => {
expect(req.data.device_data).toMatchObject({
algorithm: DEHYDRATION_ALGORITHM,
account: expect.any(String),
});
pickledAccount = req.data.device_data.account;
})
.respond(200, {
device_id: "ABCDEFG",
});
alice.httpBackend
.when("POST", "/keys/upload/ABCDEFG")
.check((req) => {
expect(req.data).toMatchObject({
"device_keys": expect.objectContaining({
algorithms: expect.any(Array),
device_id: "ABCDEFG",
user_id: "@alice:example.com",
keys: expect.objectContaining({
"ed25519:ABCDEFG": expect.any(String),
"curve25519:ABCDEFG": expect.any(String),
}),
signatures: expect.objectContaining({
"@alice:example.com": expect.objectContaining({
"ed25519:ABCDEFG": expect.any(String),
}),
}),
}),
"one_time_keys": expect.any(Object),
"org.matrix.msc2732.fallback_keys": expect.any(Object),
});
})
.respond(200, {});
try {
const deviceId = (
await Promise.all([
alice.client.createDehydratedDevice(new Uint8Array(key), {}),
alice.httpBackend.flushAllExpected(),
])
)[0];
expect(deviceId).toEqual("ABCDEFG");
expect(deviceId).not.toEqual("");
// try to rehydrate the dehydrated device
const rehydrated = new Olm.Account();
try {
rehydrated.unpickle(new Uint8Array(key), pickledAccount);
} finally {
rehydrated.free();
}
} finally {
alice.client?.crypto?.dehydrationManager?.stop();
alice.client?.crypto?.deviceList.stop();
}
});
});

View File

@ -1,58 +0,0 @@
/*
Copyright 2023 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 { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { DeviceVerification } from "../../../src";
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for DeviceInfo initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const verified = DeviceVerification.Verified;
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("deviceInfoToDevice", () => {
it("should convert a DeviceInfo to a Device", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(verified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.signatures.size).toBe(0);
});
});
});

View File

@ -1,91 +0,0 @@
/*
Copyright 2020 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 { type CryptoStore } from "../../../src/crypto/store/base";
import { IndexedDBCryptoStore } from "../../../src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "../../../src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import { RoomKeyRequestState } from "../../../src/crypto/OutgoingRoomKeyRequestManager";
import "fake-indexeddb/auto";
import "jest-localstorage-mock";
const requests = [
{
requestId: "A",
requestBody: { session_id: "A", room_id: "A", sender_key: "A", algorithm: "m.megolm.v1.aes-sha2" },
state: RoomKeyRequestState.Sent,
recipients: [
{ userId: "@alice:example.com", deviceId: "*" },
{ userId: "@becca:example.com", deviceId: "foobarbaz" },
],
},
{
requestId: "B",
requestBody: { session_id: "B", room_id: "B", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" },
state: RoomKeyRequestState.Sent,
recipients: [
{ userId: "@alice:example.com", deviceId: "*" },
{ userId: "@carrie:example.com", deviceId: "barbazquux" },
],
},
{
requestId: "C",
requestBody: { session_id: "C", room_id: "C", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" },
state: RoomKeyRequestState.Unsent,
recipients: [{ userId: "@becca:example.com", deviceId: "foobarbaz" }],
},
];
describe.each([
["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(globalThis.indexedDB, "tests")],
["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
["MemoryCryptoStore", () => new MemoryCryptoStore()],
])("Outgoing room key requests [%s]", function (name, dbFactory) {
let store: CryptoStore;
beforeAll(async () => {
store = dbFactory();
await store.startup();
await Promise.all(requests.map((request) => store.getOrAddOutgoingRoomKeyRequest(request)));
});
it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => {
const r = await store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
expect(r).toHaveLength(2);
requests
.filter((e) => e.state === RoomKeyRequestState.Sent)
.forEach((e) => {
expect(r).toContainEqual(e);
});
});
it("getOutgoingRoomKeyRequestsByTarget retrieves all entries with a given target", async () => {
const r = await store.getOutgoingRoomKeyRequestsByTarget("@becca:example.com", "foobarbaz", [
RoomKeyRequestState.Sent,
]);
expect(r).toHaveLength(1);
expect(r[0]).toEqual(requests[0]);
});
test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => {
const r = await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]);
expect(r).not.toBeNull();
expect(r).not.toBeUndefined();
expect(r!.state).toEqual(RoomKeyRequestState.Sent);
expect(requests).toContainEqual(r);
});
});

View File

@ -1,697 +0,0 @@
/*
Copyright 2019, 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 "../../olm-loader";
import * as olmlib from "../../../src/crypto/olmlib";
import { type IObject } from "../../../src/crypto/olmlib";
import { MatrixEvent } from "../../../src/models/event";
import { TestClient } from "../../TestClient";
import { makeTestClients } from "./verification/util";
import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts";
import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils";
import { logger } from "../../../src/logger";
import { ClientEvent, type ICreateClientOpts, type MatrixClient } from "../../../src/client";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { type ISignatures } from "../../../src/@types/signed";
import { type ICurve25519AuthData } from "../../../src/crypto/keybackup";
import { type SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { decodeBase64 } from "../../../src/base64";
import { type CrossSigningKeyInfo } from "../../../src/crypto-api";
import { type SecretInfo } from "../../../src/secret-storage.ts";
async function makeTestClient(
userInfo: { userId: string; deviceId: string },
options: Partial<ICreateClientOpts> = {},
) {
const client = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options).client;
// Make it seem as if we've synced and thus the store can be trusted to
// contain valid account data.
client.isInitialSyncComplete = function () {
return true;
};
await client.initLegacyCrypto();
// No need to download keys for these tests
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue(new Map());
return client;
}
// Wrapper around pkSign to return a signed object. pkSign returns the
// signature, rather than the signed object.
function sign<T extends IObject | ICurve25519AuthData>(
obj: T,
key: Uint8Array,
userId: string,
): T & {
signatures: ISignatures;
unsigned?: object;
} {
olmlib.pkSign(obj, key, userId, "");
return obj as T & {
signatures: ISignatures;
unsigned?: object;
};
}
declare module "../../../src/@types/event" {
interface SecretStorageAccountDataEvents {
foo: SecretInfo;
}
}
describe("Secrets", function () {
if (!globalThis.Olm) {
logger.warn("Not running megolm backup unit tests: libolm not present");
return;
}
beforeAll(function () {
return globalThis.Olm.init();
});
it("should store and retrieve a secret", async function () {
const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i;
const signing = new globalThis.Olm.PkSigning();
const signingKey = signing.generate_seed();
const signingPubKey = signing.init_with_seed(signingKey);
const signingkeyInfo = {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + signingPubKey]: signingPubKey,
},
};
const getKey = jest.fn().mockImplementation(async (e) => {
expect(Object.keys(e.keys)).toEqual(["abc"]);
return ["abc", key];
});
const alice = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: async (t) => signingKey,
getSecretStorageKey: getKey,
},
},
);
alice.crypto!.crossSigningInfo.setKeys({
master: signingkeyInfo,
});
const secretStorage = alice.crypto!.secretStorage;
jest.spyOn(alice, "setAccountData").mockImplementation(async function (eventType, contents) {
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: eventType,
content: contents,
}),
]);
return {};
});
const keyAccountData = {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
};
await alice.crypto!.crossSigningInfo.signObject(keyAccountData, "master");
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: "m.secret_storage.key.abc",
content: keyAccountData,
}),
]);
expect(await secretStorage.isStored("foo")).toBeFalsy();
await secretStorage.store("foo", "bar", ["abc"]);
expect(await secretStorage.isStored("foo")).toBeTruthy();
expect(await secretStorage.get("foo")).toBe("bar");
expect(getKey).toHaveBeenCalled();
alice.stopClient();
});
it("should throw if given a key that doesn't exist", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy();
alice.stopClient();
});
it("should refuse to encrypt with zero keys", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy();
alice.stopClient();
});
it("should encrypt with default key if keys is null", async function () {
const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i;
const getKey = jest.fn().mockImplementation(async (e) => {
expect(Object.keys(e.keys)).toEqual([newKeyId]);
return [newKeyId, key];
});
let keys: Record<string, Uint8Array> = {};
const alice = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: (t) => Promise.resolve(keys[t]),
saveCrossSigningKeys: (k) => (keys = k),
getSecretStorageKey: getKey,
},
},
);
alice.setAccountData = async function (eventType, contents) {
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: eventType,
content: contents,
}),
]);
return {};
};
resetCrossSigningKeys(alice);
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { key });
// we don't await on this because it waits for the event to come down the sync
// which won't happen in the test setup
alice.setDefaultSecretStorageKeyId(newKeyId);
await alice.storeSecret("foo", "bar");
const accountData = alice.getAccountData("foo");
expect(accountData!.getContent().encrypted).toBeTruthy();
alice.stopClient();
});
it("should refuse to encrypt if no keys given and no default key", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy();
alice.stopClient();
});
it("should request secrets from other clients", async function () {
const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@alice:example.com", deviceId: "VAX" },
],
{
cryptoCallbacks: {
onSecretRequested: (userId, deviceId, requestId, secretName, deviceTrust) => {
expect(secretName).toBe("foo");
return Promise.resolve("bar");
},
},
},
);
const vaxDevice = vax.client.crypto!.olmDevice;
const osborne2Device = osborne2.client.crypto!.olmDevice;
const secretStorage = osborne2.client.crypto!.secretStorage;
osborne2.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", {
VAX: {
known: false,
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:VAX": vaxDevice.deviceEd25519Key!,
"curve25519:VAX": vaxDevice.deviceCurve25519Key!,
},
verified: DeviceInfo.DeviceVerification.VERIFIED,
},
});
vax.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", {
Osborne2: {
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
verified: 0,
known: false,
keys: {
"ed25519:Osborne2": osborne2Device.deviceEd25519Key!,
"curve25519:Osborne2": osborne2Device.deviceCurve25519Key!,
},
},
});
await osborne2Device.generateOneTimeKeys(1);
const otks = (await osborne2Device.getOneTimeKeys()).curve25519;
await osborne2Device.markKeysAsPublished();
await vax.client.crypto!.olmDevice.createOutboundSession(
osborne2Device.deviceCurve25519Key!,
Object.values(otks)[0],
);
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
osborne2.client.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const request = await secretStorage.request("foo", ["VAX"]);
await request.promise; // return value not used
osborne2.stop();
vax.stop();
clearTestClientTimeouts();
});
describe("bootstrap", function () {
// keys used in some of the tests
const XSK = new Uint8Array(decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0";
const USK = new Uint8Array(decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU";
const SSK = new Uint8Array(decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q";
const SSSSKey = new Uint8Array(decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
it("bootstraps when no storage or cross-signing keys locally", async function () {
const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i;
const getKey = jest.fn().mockImplementation(async (e) => {
return [Object.keys(e.keys)[0], key];
});
const bob = await makeTestClient(
{
userId: "@bob:example.com",
deviceId: "bob1",
},
{
cryptoCallbacks: {
getSecretStorageKey: getKey,
},
},
);
bob.uploadDeviceSigningKeys = async () => ({});
bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined);
bob.setAccountData = async function (eventType, contents) {
const event = new MatrixEvent({
type: eventType,
content: contents,
});
this.store.storeAccountDataEvents([event]);
this.emit(ClientEvent.AccountData, event);
return {};
};
bob.getKeyBackupVersion = jest.fn().mockResolvedValue(null);
await bob.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
await func({});
},
});
await bob.bootstrapSecretStorage({
createSecretStorageKey,
});
const crossSigning = bob.crypto!.crossSigningInfo;
const secretStorage = bob.crypto!.secretStorage;
expect(crossSigning.getId()).toBeTruthy();
expect(await crossSigning.isStoredInSecretStorage(secretStorage)).toBeTruthy();
expect(await secretStorage.hasKey()).toBeTruthy();
bob.stopClient();
});
it("bootstraps when cross-signing keys in secret storage", async function () {
const decryption = new globalThis.Olm.PkDecryption();
const storagePrivateKey = decryption.get_private_key();
const bob: MatrixClient = await makeTestClient(
{
userId: "@bob:example.com",
deviceId: "bob1",
},
{
cryptoCallbacks: {
getSecretStorageKey: async (request) => {
const defaultKeyId = await bob.getDefaultSecretStorageKeyId();
expect(Object.keys(request.keys)).toEqual([defaultKeyId]);
return [defaultKeyId!, storagePrivateKey];
},
},
},
);
bob.uploadDeviceSigningKeys = async () => ({});
bob.uploadKeySignatures = async () => ({ failures: {} });
bob.setAccountData = async function (eventType, contents) {
const event = new MatrixEvent({
type: eventType,
content: contents,
});
this.store.storeAccountDataEvents([event]);
this.emit(ClientEvent.AccountData, event);
return {};
};
bob.crypto!.backupManager.checkKeyBackup = async () => null;
const crossSigning = bob.crypto!.crossSigningInfo;
const secretStorage = bob.crypto!.secretStorage;
// Set up cross-signing keys from scratch with specific storage key
await bob.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
await func({});
},
});
await bob.bootstrapSecretStorage({
createSecretStorageKey: async () => ({
privateKey: storagePrivateKey,
}),
});
// Clear local cross-signing keys and read from secret storage
bob.crypto!.deviceList.storeCrossSigningForUser("@bob:example.com", crossSigning.toStorage());
crossSigning.keys = {};
await bob.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
await func({});
},
});
expect(crossSigning.getId()).toBeTruthy();
expect(await crossSigning.isStoredInSecretStorage(secretStorage)).toBeTruthy();
expect(await secretStorage.hasKey()).toBeTruthy();
bob.stopClient();
});
it("adds passphrase checking if it's lacking", async function () {
let crossSigningKeys: Record<string, Uint8Array> = {
master: XSK,
user_signing: USK,
self_signing: SSK,
};
const secretStorageKeys: Record<string, Uint8Array> = {
key_id: SSSSKey,
};
const alice = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: async (t) => crossSigningKeys[t],
saveCrossSigningKeys: (k) => (crossSigningKeys = k),
getSecretStorageKey: async ({ keys }, name) => {
for (const keyId of Object.keys(keys)) {
if (secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]];
}
}
return null;
},
},
},
);
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: "m.secret_storage.default_key",
content: {
key: "key_id",
},
}),
new MatrixEvent({
type: "m.secret_storage.key.key_id",
content: {
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
passphrase: {
algorithm: "m.pbkdf2",
iterations: 500000,
salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK",
},
},
}),
// we never use these values, other than checking that they
// exist, so just use dummy values
new MatrixEvent({
type: "m.cross_signing.master",
content: {
encrypted: {
key_id: { ciphertext: "bla", mac: "bla", iv: "bla" },
},
},
}),
new MatrixEvent({
type: "m.cross_signing.self_signing",
content: {
encrypted: {
key_id: { ciphertext: "bla", mac: "bla", iv: "bla" },
},
},
}),
new MatrixEvent({
type: "m.cross_signing.user_signing",
content: {
encrypted: {
key_id: { ciphertext: "bla", mac: "bla", iv: "bla" },
},
},
}),
]);
alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", {
firstUse: false,
crossSigningVerifiedBefore: false,
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
[`ed25519:${XSPubKey}`]: XSPubKey,
},
},
self_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
[`ed25519:${SSPubKey}`]: SSPubKey,
},
},
XSK,
"@alice:example.com",
),
user_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["user_signing"],
keys: {
[`ed25519:${USPubKey}`]: USPubKey,
},
},
XSK,
"@alice:example.com",
),
},
});
alice.getKeyBackupVersion = async () => {
return {
version: "1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: sign(
{
public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A",
},
XSK,
"@alice:example.com",
),
};
};
alice.setAccountData = async function (name, data) {
const event = new MatrixEvent({
type: name,
content: data,
});
alice.store.storeAccountDataEvents([event]);
this.emit(ClientEvent.AccountData, event);
return {};
};
await alice.bootstrapSecretStorage({});
expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()).toEqual({ key: "key_id" });
const keyInfo = alice
.getAccountData("m.secret_storage.key.key_id")!
.getContent<SecretStorageKeyDescription>();
expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2");
expect(keyInfo.passphrase).toEqual({
algorithm: "m.pbkdf2",
iterations: 500000,
salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK",
});
expect(keyInfo).toHaveProperty("iv");
expect(keyInfo).toHaveProperty("mac");
expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)).toBeTruthy();
alice.stopClient();
});
it("fixes backup keys in the wrong format", async function () {
let crossSigningKeys: Record<string, Uint8Array> = {
master: XSK,
user_signing: USK,
self_signing: SSK,
};
const secretStorageKeys: Record<string, Uint8Array> = {
key_id: SSSSKey,
};
const alice = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: async (t) => crossSigningKeys[t],
saveCrossSigningKeys: (k) => (crossSigningKeys = k),
getSecretStorageKey: async ({ keys }, name) => {
for (const keyId of Object.keys(keys)) {
if (secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]];
}
}
return null;
},
},
},
);
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: "m.secret_storage.default_key",
content: {
key: "key_id",
},
}),
new MatrixEvent({
type: "m.secret_storage.key.key_id",
content: {
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
passphrase: {
algorithm: "m.pbkdf2",
iterations: 500000,
salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK",
},
},
}),
new MatrixEvent({
type: "m.cross_signing.master",
content: {
encrypted: {
key_id: { ciphertext: "bla", mac: "bla", iv: "bla" },
},
},
}),
new MatrixEvent({
type: "m.cross_signing.self_signing",
content: {
encrypted: {
key_id: { ciphertext: "bla", mac: "bla", iv: "bla" },
},
},
}),
new MatrixEvent({
type: "m.cross_signing.user_signing",
content: {
encrypted: {
key_id: { ciphertext: "bla", mac: "bla", iv: "bla" },
},
},
}),
new MatrixEvent({
type: "m.megolm_backup.v1",
content: {
encrypted: {
key_id: await encryptAESSecretStorageItem(
"123,45,6,7,89,1,234,56,78,90,12,34,5,67,8,90",
secretStorageKeys.key_id,
"m.megolm_backup.v1",
),
},
},
}),
]);
alice.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", {
firstUse: false,
crossSigningVerifiedBefore: false,
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
[`ed25519:${XSPubKey}`]: XSPubKey,
},
},
self_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
[`ed25519:${SSPubKey}`]: SSPubKey,
},
},
XSK,
"@alice:example.com",
),
user_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["user_signing"],
keys: {
[`ed25519:${USPubKey}`]: USPubKey,
},
},
XSK,
"@alice:example.com",
),
},
});
alice.getKeyBackupVersion = async () => {
return {
version: "1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: sign(
{
public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A",
},
XSK,
"@alice:example.com",
),
};
};
alice.setAccountData = async function (name, data) {
const event = new MatrixEvent({
type: name,
content: data,
});
alice.store.storeAccountDataEvents([event]);
this.emit(ClientEvent.AccountData, event);
return {};
};
await alice.bootstrapSecretStorage({});
const backupKey = alice.getAccountData("m.megolm_backup.v1")!.getContent();
expect(backupKey.encrypted).toHaveProperty("key_id");
expect(await alice.getSecret("m.megolm_backup.v1")).toEqual("ey0GB1kB6jhOWgwiBUMIWg==");
alice.stopClient();
});
});
});

View File

@ -1,91 +0,0 @@
/*
Copyright 2020 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 { type MatrixClient } from "../../../../src/client";
import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel";
import { MatrixEvent } from "../../../../src/models/event";
describe("InRoomChannel tests", function () {
const ALICE = "@alice:hs.tld";
const BOB = "@bob:hs.tld";
const MALORY = "@malory:hs.tld";
const client = {
getUserId() {
return ALICE;
},
} as unknown as MatrixClient;
it("getEventType only returns .request for a message with a msgtype", function () {
const invalidEvent = new MatrixEvent({
type: "m.key.verification.request",
});
expect(InRoomChannel.getEventType(invalidEvent)).toStrictEqual("");
const validEvent = new MatrixEvent({
type: "m.room.message",
content: { msgtype: "m.key.verification.request" },
});
expect(InRoomChannel.getEventType(validEvent)).toStrictEqual("m.key.verification.request");
const validFooEvent = new MatrixEvent({ type: "m.foo" });
expect(InRoomChannel.getEventType(validFooEvent)).toStrictEqual("m.foo");
});
it("getEventType should return m.room.message for messages", function () {
const messageEvent = new MatrixEvent({
type: "m.room.message",
content: { msgtype: "m.text" },
});
// XXX: The event type doesn't matter too much, just as long as it's not a verification event
expect(InRoomChannel.getEventType(messageEvent)).toStrictEqual("m.room.message");
});
it("getEventType should return actual type for non-message events", function () {
const event = new MatrixEvent({
type: "m.room.member",
content: {},
});
expect(InRoomChannel.getEventType(event)).toStrictEqual("m.room.member");
});
it("getOtherPartyUserId should not return anything for a request not " + "directed at me", function () {
const event = new MatrixEvent({
sender: BOB,
type: "m.room.message",
content: { msgtype: "m.key.verification.request", to: MALORY },
});
expect(InRoomChannel.getOtherPartyUserId(event, client)).toStrictEqual(undefined);
});
it("getOtherPartyUserId should not return anything an event that is not of a valid " + "request type", function () {
// invalid because this should be a room message with msgtype
const invalidRequest = new MatrixEvent({
sender: BOB,
type: "m.key.verification.request",
content: { to: ALICE },
});
expect(InRoomChannel.getOtherPartyUserId(invalidRequest, client)).toStrictEqual(undefined);
const startEvent = new MatrixEvent({
sender: BOB,
type: "m.key.verification.start",
content: { to: ALICE },
});
expect(InRoomChannel.getOtherPartyUserId(startEvent, client)).toStrictEqual(undefined);
const fooEvent = new MatrixEvent({
sender: BOB,
type: "m.foo",
content: { to: ALICE },
});
expect(InRoomChannel.getOtherPartyUserId(fooEvent, client)).toStrictEqual(undefined);
});
});

View File

@ -1,41 +0,0 @@
/*
Copyright 2018-2019 New Vector Ltd
Copyright 2019 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 "../../../olm-loader";
import { logger } from "../../../../src/logger";
const Olm = globalThis.Olm;
describe("QR code verification", function () {
if (!globalThis.Olm) {
logger.warn("Not running device verification tests: libolm not present");
return;
}
beforeAll(function () {
return Olm.init();
});
describe("reciprocate", () => {
it("should verify the secret", () => {
// TODO: Actually write a test for this.
// Tests are hard because we are running before the verification
// process actually begins, and are largely UI-driven rather than
// logic-driven (compared to something like SAS). In the interest
// of time, tests are currently excluded.
});
});
});

View File

@ -1,80 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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 "../../../olm-loader";
import { CryptoEvent, verificationMethods } from "../../../../src/crypto";
import { logger } from "../../../../src/logger";
import { SAS } from "../../../../src/crypto/verification/SAS";
import { makeTestClients } from "./util";
const Olm = globalThis.Olm;
jest.useFakeTimers();
describe("verification request integration tests with crypto layer", function () {
if (!globalThis.Olm) {
logger.warn("Not running device verification unit tests: libolm not present");
return;
}
beforeAll(function () {
return Olm.init();
});
it("should request and accept a verification", async function () {
const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
],
{
verificationMethods: [verificationMethods.SAS],
},
);
alice.client.crypto!.deviceList.getRawStoredDevicesForUser = function () {
return {
Dynabook: {
algorithms: [],
verified: 0,
known: false,
keys: {
"ed25519:Dynabook": "bob+base64+ed25519+key",
},
},
};
};
alice.client.downloadKeys = jest.fn().mockResolvedValue({});
bob.client.downloadKeys = jest.fn().mockResolvedValue({});
bob.client.on(CryptoEvent.VerificationRequest, (request) => {
const bobVerifier = request.beginKeyVerification(verificationMethods.SAS);
bobVerifier.verify();
// @ts-ignore Private function access (but it's a test, so we're okay)
bobVerifier.endTimer();
});
const aliceRequest = await alice.client.requestVerification("@bob:example.com");
await aliceRequest.waitFor((r) => r.started);
const aliceVerifier = aliceRequest.verifier;
expect(aliceVerifier).toBeInstanceOf(SAS);
// @ts-ignore Private function access (but it's a test, so we're okay)
aliceVerifier.endTimer();
alice.stop();
bob.stop();
clearTestClientTimeouts();
});
});

View File

@ -1,581 +0,0 @@
/*
Copyright 2018-2019 New Vector Ltd
Copyright 2019 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 "../../../olm-loader";
import { makeTestClients } from "./util";
import { MatrixEvent } from "../../../../src/models/event";
import { type ISasEvent, SAS, SasEvent } from "../../../../src/crypto/verification/SAS";
import { DeviceInfo, type IDevice } from "../../../../src/crypto/deviceinfo";
import { CryptoEvent, verificationMethods } from "../../../../src/crypto";
import * as olmlib from "../../../../src/crypto/olmlib";
import { logger } from "../../../../src/logger";
import { resetCrossSigningKeys } from "../crypto-utils";
import { type VerificationBase } from "../../../../src/crypto/verification/Base";
import { type IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { type MatrixClient } from "../../../../src";
import { type VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
import { type TestClient } from "../../../TestClient";
const Olm = globalThis.Olm;
let ALICE_DEVICES: Record<string, IDevice>;
let BOB_DEVICES: Record<string, IDevice>;
describe("SAS verification", function () {
if (!globalThis.Olm) {
logger.warn("Not running device verification unit tests: libolm not present");
return;
}
beforeAll(function () {
return Olm.init();
});
it("should error on an unexpected event", async function () {
//channel, baseApis, userId, deviceId, startEvent, request
const request = {
onVerifierCancelled: function () {},
} as VerificationRequest;
const channel = {
send: function () {
return Promise.resolve();
},
} as unknown as IVerificationChannel;
const mockClient = {} as unknown as MatrixClient;
const event = new MatrixEvent({ type: "test" });
const sas = new SAS(channel, mockClient, "@alice:example.com", "ABCDEFG", event, request);
sas.handleEvent(
new MatrixEvent({
sender: "@alice:example.com",
type: "es.inquisition",
content: {},
}),
);
const spy = jest.fn();
await sas.verify().catch(spy);
expect(spy).toHaveBeenCalled();
// Cancel the SAS for cleanup (we started a verification, so abort)
sas.cancel(new Error("error"));
});
describe("verification", () => {
let alice: TestClient;
let bob: TestClient;
let aliceSasEvent: ISasEvent | null;
let bobSasEvent: ISasEvent | null;
let aliceVerifier: SAS;
let bobPromise: Promise<VerificationBase<any, any>>;
let clearTestClientTimeouts: () => void;
beforeEach(async () => {
[[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
],
{
verificationMethods: [verificationMethods.SAS],
},
);
const aliceDevice = alice.client.crypto!.olmDevice;
const bobDevice = bob.client.crypto!.olmDevice;
ALICE_DEVICES = {
Osborne2: {
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:Osborne2": aliceDevice.deviceEd25519Key!,
"curve25519:Osborne2": aliceDevice.deviceCurve25519Key!,
},
verified: DeviceInfo.DeviceVerification.UNVERIFIED,
known: false,
},
};
BOB_DEVICES = {
Dynabook: {
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:Dynabook": bobDevice.deviceEd25519Key!,
"curve25519:Dynabook": bobDevice.deviceCurve25519Key!,
},
verified: DeviceInfo.DeviceVerification.UNVERIFIED,
known: false,
},
};
alice.client.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES);
alice.client.downloadKeys = () => {
return Promise.resolve(new Map());
};
bob.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", ALICE_DEVICES);
bob.client.downloadKeys = () => {
return Promise.resolve(new Map());
};
aliceSasEvent = null;
bobSasEvent = null;
bobPromise = new Promise<VerificationBase<any, any>>((resolve, reject) => {
bob.client.on(CryptoEvent.VerificationRequest, (request) => {
(<SAS>request.verifier!).on(SasEvent.ShowSas, (e) => {
if (!e.sas.emoji || !e.sas.decimal) {
e.cancel();
} else if (!aliceSasEvent) {
bobSasEvent = e;
} else {
try {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
}
});
resolve(request.verifier!);
});
});
aliceVerifier = alice.client.beginKeyVerification(
verificationMethods.SAS,
bob.client.getUserId()!,
bob.deviceId!,
) as SAS;
aliceVerifier.on(SasEvent.ShowSas, (e) => {
if (!e.sas.emoji || !e.sas.decimal) {
e.cancel();
} else if (!bobSasEvent) {
aliceSasEvent = e;
} else {
try {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
}
});
});
afterEach(async () => {
await Promise.all([alice.stop(), bob.stop()]);
clearTestClientTimeouts();
});
it("should verify a key", async () => {
let macMethod;
let keyAgreement;
const origSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = async (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.message_authentication_code;
keyAgreement = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.key_agreement_protocol;
}
return origSendToDevice(type, map);
};
alice.httpBackend.when("POST", "/keys/query").respond(200, {
failures: {},
device_keys: {
"@bob:example.com": BOB_DEVICES,
},
});
bob.httpBackend.when("POST", "/keys/query").respond(200, {
failures: {},
device_keys: {
"@alice:example.com": ALICE_DEVICES,
},
});
await Promise.all([
aliceVerifier.verify(),
bobPromise.then((verifier) => verifier.verify()),
alice.httpBackend.flush(undefined),
bob.httpBackend.flush(undefined),
]);
// make sure that it uses the preferred method
expect(macMethod).toBe("hkdf-hmac-sha256.v2");
expect(keyAgreement).toBe("curve25519-hkdf-sha256");
// make sure Alice and Bob verified each other
const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook");
expect(bobDevice?.isVerified()).toBeTruthy();
const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2");
expect(aliceDevice?.isVerified()).toBeTruthy();
});
it("should be able to verify using the old base64", async () => {
// pretend that Alice can only understand the old (incorrect) base64
// encoding, and make sure that she can still verify with Bob
let macMethod;
const aliceOrigSendToDevice = alice.client.sendToDevice.bind(alice.client);
alice.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.start") {
// Note: this modifies not only the message that Bob
// receives, but also the copy of the message that Alice
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
"hkdf-hmac-sha256",
];
}
return aliceOrigSendToDevice(type, map);
};
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map
.get(alice.client.getUserId()!)!
.get(alice.client.deviceId!)!.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
alice.httpBackend.when("POST", "/keys/query").respond(200, {
failures: {},
device_keys: {
"@bob:example.com": BOB_DEVICES,
},
});
bob.httpBackend.when("POST", "/keys/query").respond(200, {
failures: {},
device_keys: {
"@alice:example.com": ALICE_DEVICES,
},
});
await Promise.all([
aliceVerifier.verify(),
bobPromise.then((verifier) => verifier.verify()),
alice.httpBackend.flush(undefined),
bob.httpBackend.flush(undefined),
]);
expect(macMethod).toBe("hkdf-hmac-sha256");
const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook");
expect(bobDevice!.isVerified()).toBeTruthy();
const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2");
expect(aliceDevice!.isVerified()).toBeTruthy();
});
it("should be able to verify using the old MAC", async () => {
// pretend that Alice can only understand the old (incorrect) MAC,
// and make sure that she can still verify with Bob
let macMethod;
const aliceOrigSendToDevice = alice.client.sendToDevice.bind(alice.client);
alice.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.start") {
// Note: this modifies not only the message that Bob
// receives, but also the copy of the message that Alice
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
"hmac-sha256",
];
}
return aliceOrigSendToDevice(type, map);
};
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map
.get(alice.client.getUserId()!)!
.get(alice.client.deviceId!)!.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
alice.httpBackend.when("POST", "/keys/query").respond(200, {
failures: {},
device_keys: {
"@bob:example.com": BOB_DEVICES,
},
});
bob.httpBackend.when("POST", "/keys/query").respond(200, {
failures: {},
device_keys: {
"@alice:example.com": ALICE_DEVICES,
},
});
await Promise.all([
aliceVerifier.verify(),
bobPromise.then((verifier) => verifier.verify()),
alice.httpBackend.flush(undefined),
bob.httpBackend.flush(undefined),
]);
expect(macMethod).toBe("hmac-sha256");
const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook");
expect(bobDevice?.isVerified()).toBeTruthy();
const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2");
expect(aliceDevice?.isVerified()).toBeTruthy();
});
it("should verify a cross-signing key", async () => {
alice.httpBackend.when("POST", "/keys/device_signing/upload").respond(200, {});
alice.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {});
alice.httpBackend.flush(undefined, 2);
await resetCrossSigningKeys(alice.client);
bob.httpBackend.when("POST", "/keys/device_signing/upload").respond(200, {});
bob.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {});
bob.httpBackend.flush(undefined, 2);
await resetCrossSigningKeys(bob.client);
bob.client.crypto!.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: alice.client.crypto!.crossSigningInfo.keys,
crossSigningVerifiedBefore: false,
firstUse: true,
});
const verifyProm = Promise.all([
aliceVerifier.verify(),
bobPromise.then((verifier) => {
bob.httpBackend.when("POST", "/keys/signatures/upload").respond(200, {});
bob.httpBackend.flush(undefined, 1, 2000);
return verifier.verify();
}),
]);
await verifyProm;
const bobDeviceTrust = alice.client.checkDeviceTrust("@bob:example.com", "Dynabook");
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const bobDeviceVerificationStatus = (await alice.client
.getCrypto()!
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
expect(aliceTrust.isTofu()).toBeTruthy();
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const aliceDeviceVerificationStatus = (await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
const unknownDeviceVerificationStatus = await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "xyz");
expect(unknownDeviceVerificationStatus).toBe(null);
});
});
it("should send a cancellation message on error", async function () {
const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
],
{
verificationMethods: [verificationMethods.SAS],
},
);
alice.client.setDeviceVerified = jest.fn();
alice.client.downloadKeys = jest.fn().mockResolvedValue({});
bob.client.setDeviceVerified = jest.fn();
bob.client.downloadKeys = jest.fn().mockResolvedValue({});
const bobPromise = new Promise<VerificationBase<any, any>>((resolve, reject) => {
bob.client.on(CryptoEvent.VerificationRequest, (request) => {
(<SAS>request.verifier!).on(SasEvent.ShowSas, (e) => {
e.mismatch();
});
resolve(request.verifier!);
});
});
const aliceVerifier = alice.client.beginKeyVerification(
verificationMethods.SAS,
bob.client.getUserId()!,
bob.client.deviceId!,
);
const aliceSpy = jest.fn();
const bobSpy = jest.fn();
await Promise.all([
aliceVerifier.verify().catch(aliceSpy),
bobPromise.then((verifier) => verifier.verify()).catch(bobSpy),
]);
expect(aliceSpy).toHaveBeenCalled();
expect(bobSpy).toHaveBeenCalled();
expect(alice.client.setDeviceVerified).not.toHaveBeenCalled();
expect(bob.client.setDeviceVerified).not.toHaveBeenCalled();
alice.stop();
bob.stop();
clearTestClientTimeouts();
});
describe("verification in DM", function () {
let alice: TestClient;
let bob: TestClient;
let aliceSasEvent: ISasEvent | null;
let bobSasEvent: ISasEvent | null;
let aliceVerifier: SAS;
let bobPromise: Promise<void>;
let clearTestClientTimeouts: () => void;
beforeEach(async function () {
[[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
],
{
verificationMethods: [verificationMethods.SAS],
},
);
alice.client.crypto!.setDeviceVerification = jest.fn();
alice.client.getDeviceEd25519Key = () => {
return "alice+base64+ed25519+key";
};
alice.client.getStoredDevice = () => {
return DeviceInfo.fromStorage(
{
keys: {
"ed25519:Dynabook": "bob+base64+ed25519+key",
},
},
"Dynabook",
);
};
alice.client.downloadKeys = () => {
return Promise.resolve(new Map());
};
bob.client.crypto!.setDeviceVerification = jest.fn();
bob.client.getStoredDevice = () => {
return DeviceInfo.fromStorage(
{
keys: {
"ed25519:Osborne2": "alice+base64+ed25519+key",
},
},
"Osborne2",
);
};
bob.client.getDeviceEd25519Key = () => {
return "bob+base64+ed25519+key";
};
bob.client.downloadKeys = () => {
return Promise.resolve(new Map());
};
aliceSasEvent = null;
bobSasEvent = null;
bobPromise = new Promise<void>((resolve, reject) => {
bob.client.on(CryptoEvent.VerificationRequest, async (request) => {
const verifier = request.beginKeyVerification(SAS.NAME) as SAS;
verifier.on(SasEvent.ShowSas, (e) => {
if (!e.sas.emoji || !e.sas.decimal) {
e.cancel();
} else if (!aliceSasEvent) {
bobSasEvent = e;
} else {
try {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
}
});
await verifier.verify();
resolve();
});
});
const aliceRequest = await alice.client.requestVerificationDM(bob.client.getUserId()!, "!room_id");
await aliceRequest.waitFor((r) => r.started);
aliceVerifier = aliceRequest.verifier! as SAS;
aliceVerifier.on(SasEvent.ShowSas, (e) => {
if (!e.sas.emoji || !e.sas.decimal) {
e.cancel();
} else if (!bobSasEvent) {
aliceSasEvent = e;
} else {
try {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
}
});
});
afterEach(async function () {
await Promise.all([alice.stop(), bob.stop()]);
clearTestClientTimeouts();
});
it("should verify a key", async function () {
await Promise.all([aliceVerifier.verify(), bobPromise]);
// make sure Alice and Bob verified each other
expect(alice.client.crypto!.setDeviceVerification).toHaveBeenCalledWith(
bob.client.getUserId(),
bob.client.deviceId,
true,
null,
null,
{ "ed25519:Dynabook": "bob+base64+ed25519+key" },
);
expect(bob.client.crypto!.setDeviceVerification).toHaveBeenCalledWith(
alice.client.getUserId(),
alice.client.deviceId,
true,
null,
null,
{ "ed25519:Osborne2": "alice+base64+ed25519+key" },
);
});
});
});

View File

@ -1,122 +0,0 @@
/*
Copyright 2020 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 "../../../olm-loader";
import { type MatrixClient, type MatrixEvent } from "../../../../src/matrix";
import { encodeBase64 } from "../../../../src/base64";
import "../../../../src/crypto"; // import this to cycle-break
import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning";
import { type VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
import { type IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
jest.useFakeTimers();
// Private key for tests only
const testKey = new Uint8Array([
0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05,
0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d,
]);
const testKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk";
describe("self-verifications", () => {
beforeAll(function () {
return globalThis.Olm.init();
});
it("triggers a request for key sharing upon completion", async () => {
const userId = "@test:localhost";
const cacheCallbacks = {
getCrossSigningKeyCache: jest.fn().mockReturnValue(null),
storeCrossSigningKeyCache: jest.fn(),
};
const crossSigningInfo = new CrossSigningInfo(userId, {}, cacheCallbacks);
crossSigningInfo.keys = {
master: {
keys: { X: testKeyPub },
usage: [],
user_id: "user-id",
},
self_signing: {
keys: { X: testKeyPub },
usage: [],
user_id: "user-id",
},
user_signing: {
keys: { X: testKeyPub },
usage: [],
user_id: "user-id",
},
};
const secretStorage = {
request: jest.fn().mockReturnValue({
promise: Promise.resolve(encodeBase64(testKey)),
}),
};
const storeSessionBackupPrivateKey = jest.fn();
const restoreKeyBackupWithCache = jest.fn(() => Promise.resolve());
const client = {
crypto: {
crossSigningInfo,
secretStorage,
storeSessionBackupPrivateKey,
getSessionBackupPrivateKey: () => null,
},
requestSecret: secretStorage.request.bind(secretStorage),
getUserId: () => userId,
getKeyBackupVersion: () => Promise.resolve({}),
restoreKeyBackupWithCache,
} as unknown as MatrixClient;
const request = {
onVerifierFinished: () => undefined,
} as unknown as VerificationRequest;
const verification = new VerificationBase(
undefined as unknown as IVerificationChannel, // channel
client, // baseApis
userId,
"ABC", // deviceId
undefined as unknown as MatrixEvent, // startEvent
request,
);
// @ts-ignore set private property
verification.resolve = () => undefined;
const result = await verification.done();
/* We should request, and store, 3 cross signing keys and the key backup key */
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3);
expect(secretStorage.request.mock.calls.length).toBe(4);
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]).toEqual(testKey);
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1]).toEqual(testKey);
expect(storeSessionBackupPrivateKey.mock.calls[0][0]).toEqual(testKey);
expect(restoreKeyBackupWithCache).toHaveBeenCalled();
expect(result).toBeInstanceOf(Array);
expect(result![0][0]).toBe(testKeyPub);
expect(result![1][0]).toBe(testKeyPub);
});
});

View File

@ -1,134 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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 { TestClient } from "../../../TestClient";
import { type IContent, MatrixEvent } from "../../../../src/models/event";
import { type IRoomTimelineData } from "../../../../src/models/event-timeline-set";
import { Room, RoomEvent } from "../../../../src/models/room";
import { logger } from "../../../../src/logger";
import {
type MatrixClient,
ClientEvent,
type ICreateClientOpts,
type SendToDeviceContentMap,
} from "../../../../src/client";
interface UserInfo {
userId: string;
deviceId: string;
}
export async function makeTestClients(
userInfos: UserInfo[],
options: Partial<ICreateClientOpts>,
): Promise<[TestClient[], () => void]> {
const clients: TestClient[] = [];
const timeouts: ReturnType<typeof setTimeout>[] = [];
const clientMap: Record<string, Record<string, MatrixClient>> = {};
const makeSendToDevice =
(matrixClient: MatrixClient): MatrixClient["sendToDevice"] =>
async (type: string, contentMap: SendToDeviceContentMap) => {
// logger.log(this.getUserId(), "sends", type, map);
for (const [userId, deviceMessages] of contentMap) {
if (userId in clientMap) {
for (const [deviceId, message] of deviceMessages) {
if (deviceId in clientMap[userId]) {
const event = new MatrixEvent({
sender: matrixClient.getUserId()!,
type: type,
content: message,
});
const client = clientMap[userId][deviceId];
const decryptionPromise = event.isEncrypted()
? event.attemptDecryption(client.crypto!)
: Promise.resolve();
decryptionPromise.then(() => client.emit(ClientEvent.ToDeviceEvent, event));
}
}
}
}
return {};
};
const makeSendEvent = (matrixClient: MatrixClient) => (room: string, type: string, content: IContent) => {
// make up a unique ID as the event ID
const eventId = "$" + matrixClient.makeTxnId();
const rawEvent = {
sender: matrixClient.getUserId()!,
type: type,
content: content,
room_id: room,
event_id: eventId,
origin_server_ts: Date.now(),
};
const event = new MatrixEvent(rawEvent);
const remoteEcho = new MatrixEvent(
Object.assign({}, rawEvent, {
unsigned: {
transaction_id: matrixClient.makeTxnId(),
},
}),
);
const timeout = setTimeout(() => {
for (const tc of clients) {
const room = new Room("test", tc.client, tc.client.getUserId()!);
const roomTimelineData = {} as unknown as IRoomTimelineData;
if (tc.client === matrixClient) {
logger.log("sending remote echo!!");
tc.client.emit(RoomEvent.Timeline, remoteEcho, room, false, false, roomTimelineData);
} else {
tc.client.emit(RoomEvent.Timeline, event, room, false, false, roomTimelineData);
}
}
});
timeouts.push(timeout as unknown as ReturnType<typeof setTimeout>);
return Promise.resolve({ event_id: eventId });
};
for (const userInfo of userInfos) {
let keys: Record<string, Uint8Array> = {};
if (!options) options = {};
if (!options.cryptoCallbacks) options.cryptoCallbacks = {};
if (!options.cryptoCallbacks.saveCrossSigningKeys) {
options.cryptoCallbacks.saveCrossSigningKeys = (k) => {
keys = k;
};
// @ts-ignore tsc getting confused by overloads
options.cryptoCallbacks.getCrossSigningKey = (typ) => keys[typ];
}
const testClient = new TestClient(userInfo.userId, userInfo.deviceId, undefined, undefined, options);
if (!(userInfo.userId in clientMap)) {
clientMap[userInfo.userId] = {};
}
clientMap[userInfo.userId][userInfo.deviceId] = testClient.client;
testClient.client.sendToDevice = makeSendToDevice(testClient.client);
// @ts-ignore tsc getting confused by overloads
testClient.client.sendEvent = makeSendEvent(testClient.client);
clients.push(testClient);
}
await Promise.all(clients.map((testClient) => testClient.client.initLegacyCrypto()));
const destroy = () => {
timeouts.forEach((t) => clearTimeout(t));
};
return [clients, destroy];
}

View File

@ -1,331 +0,0 @@
/*
Copyright 2020 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 {
VerificationRequest,
READY_TYPE,
START_TYPE,
DONE_TYPE,
} from "../../../../src/crypto/verification/request/VerificationRequest";
import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel";
import { ToDeviceChannel } from "../../../../src/crypto/verification/request/ToDeviceChannel";
import { type IContent, MatrixEvent } from "../../../../src/models/event";
import { type MatrixClient } from "../../../../src/client";
import { type IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
import { MapWithDefault } from "../../../../src/utils";
type MockClient = MatrixClient & {
popEvents: () => MatrixEvent[];
popDeviceEvents: (userId: string, deviceId: string) => MatrixEvent[];
};
function makeMockClient(userId: string, deviceId: string): MockClient {
let counter = 1;
let events: MatrixEvent[] = [];
const deviceEvents: MapWithDefault<string, MapWithDefault<string, MatrixEvent[]>> = new MapWithDefault(
() => new MapWithDefault(() => []),
);
return {
getUserId() {
return userId;
},
getDeviceId() {
return deviceId;
},
sendEvent(roomId: string, type: string, content: IContent) {
counter = counter + 1;
const eventId = `$${userId}-${deviceId}-${counter}`;
events.push(
new MatrixEvent({
sender: userId,
event_id: eventId,
room_id: roomId,
type,
content,
origin_server_ts: Date.now(),
}),
);
return Promise.resolve({ event_id: eventId });
},
sendToDevice(type: string, msgMap: Map<string, Map<string, IContent>>) {
for (const [userId, deviceMessages] of msgMap) {
for (const [deviceId, content] of deviceMessages) {
const event = new MatrixEvent({ content, type });
deviceEvents.getOrCreate(userId).getOrCreate(deviceId).push(event);
}
}
return Promise.resolve({});
},
// @ts-ignore special testing fn
popEvents(): MatrixEvent[] {
const e = events;
events = [];
return e;
},
popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] {
const result = deviceEvents.get(userId)?.get(deviceId) || [];
deviceEvents?.get(userId)?.delete(deviceId);
return result;
},
} as unknown as MockClient;
}
const MOCK_METHOD = "mock-verify";
class MockVerifier extends VerificationBase<"", any> {
public _channel;
public _startEvent;
constructor(
channel: IVerificationChannel,
client: MatrixClient,
userId: string,
deviceId: string,
startEvent: MatrixEvent,
) {
super(channel, client, userId, deviceId, startEvent, {} as unknown as VerificationRequest);
this._channel = channel;
this._startEvent = startEvent;
}
get events() {
return [DONE_TYPE];
}
async start() {
if (this._startEvent) {
await this._channel.send(DONE_TYPE, {});
} else {
await this._channel.send(START_TYPE, { method: MOCK_METHOD });
}
}
async handleEvent(event: MatrixEvent) {
if (event.getType() === DONE_TYPE && !this._startEvent) {
await this._channel.send(DONE_TYPE, {});
}
}
canSwitchStartEvent() {
return false;
}
}
function makeRemoteEcho(event: MatrixEvent) {
return new MatrixEvent(
Object.assign({}, event.event, {
unsigned: {
transaction_id: "abc",
},
}),
);
}
async function distributeEvent(
ownRequest: VerificationRequest,
theirRequest: VerificationRequest,
event: MatrixEvent,
): Promise<void> {
await ownRequest.channel.handleEvent(makeRemoteEcho(event), ownRequest, true);
await theirRequest.channel.handleEvent(event, theirRequest, true);
}
jest.useFakeTimers();
describe("verification request unit tests", function () {
it("transition from UNSENT to DONE through happy path", async function () {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const verificationMethods = new Map([[MOCK_METHOD, MockVerifier]]) as unknown as Map<
string,
typeof VerificationBase
>;
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()!),
verificationMethods,
alice,
);
const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), verificationMethods, bob);
expect(aliceRequest.invalid).toBe(true);
expect(bobRequest.invalid).toBe(true);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
expect(requestEvent.getType()).toBe("m.room.message");
await distributeEvent(aliceRequest, bobRequest, requestEvent);
expect(aliceRequest.requested).toBe(true);
expect(bobRequest.requested).toBe(true);
await bobRequest.accept();
const [readyEvent] = bob.popEvents();
expect(readyEvent.getType()).toBe(READY_TYPE);
await distributeEvent(bobRequest, aliceRequest, readyEvent);
expect(bobRequest.ready).toBe(true);
expect(aliceRequest.ready).toBe(true);
const verifier = aliceRequest.beginKeyVerification(MOCK_METHOD);
await (verifier as MockVerifier).start();
const [startEvent] = alice.popEvents();
expect(startEvent.getType()).toBe(START_TYPE);
await distributeEvent(aliceRequest, bobRequest, startEvent);
expect(aliceRequest.started).toBe(true);
expect(aliceRequest.verifier).toBeInstanceOf(MockVerifier);
expect(bobRequest.started).toBe(true);
expect(bobRequest.verifier).toBeInstanceOf(MockVerifier);
await (bobRequest.verifier as MockVerifier).start();
const [bobDoneEvent] = bob.popEvents();
expect(bobDoneEvent.getType()).toBe(DONE_TYPE);
await distributeEvent(bobRequest, aliceRequest, bobDoneEvent);
const [aliceDoneEvent] = alice.popEvents();
expect(aliceDoneEvent.getType()).toBe(DONE_TYPE);
await distributeEvent(aliceRequest, bobRequest, aliceDoneEvent);
expect(aliceRequest.done).toBe(true);
expect(bobRequest.done).toBe(true);
});
it("methods only contains common methods", async function () {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const aliceVerificationMethods = new Map([
["c", function () {}],
["a", function () {}],
]) as unknown as Map<string, typeof VerificationBase>;
const bobVerificationMethods = new Map([
["c", function () {}],
["b", function () {}],
]) as unknown as Map<string, typeof VerificationBase>;
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()!),
aliceVerificationMethods,
alice,
);
const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), bobVerificationMethods, bob);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
await distributeEvent(aliceRequest, bobRequest, requestEvent);
await bobRequest.accept();
const [readyEvent] = bob.popEvents();
await distributeEvent(bobRequest, aliceRequest, readyEvent);
expect(aliceRequest.methods).toStrictEqual(["c"]);
expect(bobRequest.methods).toStrictEqual(["c"]);
});
it("other client accepting request puts it in observeOnly mode", async function () {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob1 = makeMockClient("@bob:matrix.tld", "device1");
const bob2 = makeMockClient("@bob:matrix.tld", "device2");
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob1.getUserId()!),
new Map(),
alice,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
const bob1Request = new VerificationRequest(new InRoomChannel(bob1, "!room"), new Map(), bob1);
const bob2Request = new VerificationRequest(new InRoomChannel(bob2, "!room"), new Map(), bob2);
await bob1Request.channel.handleEvent(requestEvent, bob1Request, true);
await bob2Request.channel.handleEvent(requestEvent, bob2Request, true);
await bob1Request.accept();
const [readyEvent] = bob1.popEvents();
expect(bob2Request.observeOnly).toBe(false);
await bob2Request.channel.handleEvent(readyEvent, bob2Request, true);
expect(bob2Request.observeOnly).toBe(true);
});
it("verify own device with to_device messages", async function () {
const bob1 = makeMockClient("@bob:matrix.tld", "device1");
const bob2 = makeMockClient("@bob:matrix.tld", "device2");
const verificationMethods = new Map([[MOCK_METHOD, MockVerifier]]) as unknown as Map<
string,
typeof VerificationBase
>;
const bob1Request = new VerificationRequest(
new ToDeviceChannel(
bob1,
bob1.getUserId()!,
["device1", "device2"],
ToDeviceChannel.makeTransactionId(),
"device2",
),
verificationMethods,
bob1,
);
const to = { userId: "@bob:matrix.tld", deviceId: "device2" };
const verifier = bob1Request.beginKeyVerification(MOCK_METHOD, to);
expect(verifier).toBeInstanceOf(MockVerifier);
await (verifier as MockVerifier).start();
const [startEvent] = bob1.popDeviceEvents(to.userId, to.deviceId);
expect(startEvent.getType()).toBe(START_TYPE);
const bob2Request = new VerificationRequest(
new ToDeviceChannel(bob2, bob2.getUserId()!, ["device1"]),
verificationMethods,
bob2,
);
await bob2Request.channel.handleEvent(startEvent, bob2Request, true);
await (bob2Request.verifier as MockVerifier).start();
const [doneEvent1] = bob2.popDeviceEvents("@bob:matrix.tld", "device1");
expect(doneEvent1.getType()).toBe(DONE_TYPE);
await bob1Request.channel.handleEvent(doneEvent1, bob1Request, true);
const [doneEvent2] = bob1.popDeviceEvents("@bob:matrix.tld", "device2");
expect(doneEvent2.getType()).toBe(DONE_TYPE);
await bob2Request.channel.handleEvent(doneEvent2, bob2Request, true);
expect(bob1Request.done).toBe(true);
expect(bob2Request.done).toBe(true);
});
it("request times out after 10 minutes", async function () {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()!),
new Map(),
alice,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true);
expect(aliceRequest.cancelled).toBe(false);
expect(aliceRequest._cancellingUserId).toBe(undefined);
jest.advanceTimersByTime(10 * 60 * 1000);
expect(aliceRequest._cancellingUserId).toBe(alice.getUserId());
});
it("request times out 2 minutes after receipt", async function () {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()!),
new Map(),
alice,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
const bobRequest = new VerificationRequest(new InRoomChannel(bob, "!room"), new Map(), bob);
await bobRequest.channel.handleEvent(requestEvent, bobRequest, true);
expect(bobRequest.cancelled).toBe(false);
expect(bobRequest._cancellingUserId).toBe(undefined);
jest.advanceTimersByTime(2 * 60 * 1000);
expect(bobRequest._cancellingUserId).toBe(bob.getUserId());
});
});

View File

@ -40,7 +40,6 @@ import { SyncState } from "../../src/sync";
import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
import { MatrixEvent } from "../../src/models/event";
import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { sleep } from "../../src/utils";
const testOIDCToken = {
@ -728,10 +727,11 @@ describe("RoomWidgetClient", () => {
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
const payload = { type: "org.example.foo", hello: "world" };
await client.encryptAndSendToDevices(
const embeddedClient = client as RoomWidgetClient;
await embeddedClient.encryptAndSendToDevices(
[
{ userId: "@alice:example.org", deviceInfo: new DeviceInfo("aliceWeb") },
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobDesktop") },
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
],
payload,
);

View File

@ -37,8 +37,6 @@ import {
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
import { ReceiptType } from "../../src/@types/read_receipts";
@ -74,16 +72,15 @@ import {
PolicyRecommendation,
PolicyScope,
} from "../../src/models/invites-ignorer";
import { type IOlmDevice } from "../../src/crypto/algorithms/megolm";
import { defer, type QueryDict } from "../../src/utils";
import { type SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
import { type SecretStorageKeyDescriptionAesV1, type ServerSideSecretStorageImpl } from "../../src/secret-storage";
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { type ServerSideSecretStorageImpl } from "../../src/secret-storage";
import { KnownMembership } from "../../src/@types/membership";
import { type RoomMessageEventContent } from "../../src/@types/events";
import { mockOpenIdConfiguration } from "../test-utils/oidc.ts";
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
jest.useFakeTimers();
@ -1196,7 +1193,7 @@ describe("MatrixClient", function () {
type: EventType.RoomEncryption,
state_key: "",
content: {
algorithm: MEGOLM_ALGORITHM,
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
@ -1922,7 +1919,7 @@ describe("MatrixClient", function () {
hasEncryptionStateEvent: jest.fn().mockReturnValue(true),
} as unknown as Room;
let mockCrypto: Mocked<Crypto>;
let mockCrypto: Mocked<CryptoBackend>;
let event: MatrixEvent;
beforeEach(async () => {
@ -1942,8 +1939,8 @@ describe("MatrixClient", function () {
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true),
encryptEvent: jest.fn(),
stop: jest.fn(),
} as unknown as Mocked<Crypto>;
client.crypto = client["cryptoBackend"] = mockCrypto;
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockCrypto;
});
function assertCancelled() {
@ -2329,21 +2326,6 @@ describe("MatrixClient", function () {
});
});
describe("encryptAndSendToDevices", () => {
it("throws an error if crypto is unavailable", () => {
client.crypto = undefined;
expect(() => client.encryptAndSendToDevices([], {})).toThrow();
});
it("is an alias for the crypto method", async () => {
client.crypto = testUtils.mock(Crypto, "Crypto");
const deviceInfos: IOlmDevice[] = [];
const payload = {};
await client.encryptAndSendToDevices(deviceInfos, payload);
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
});
});
describe("support for ignoring invites", () => {
beforeEach(() => {
// Mockup `getAccountData`/`setAccountData`.
@ -3205,24 +3187,6 @@ describe("MatrixClient", function () {
client["_secretStorage"] = mockSecretStorage;
});
it("hasSecretStorageKey", async () => {
mockSecretStorage.hasKey.mockResolvedValue(false);
expect(await client.hasSecretStorageKey("mykey")).toBe(false);
expect(mockSecretStorage.hasKey).toHaveBeenCalledWith("mykey");
});
it("isSecretStored", async () => {
const mockResult = { key: {} as SecretStorageKeyDescriptionAesV1 };
mockSecretStorage.isStored.mockResolvedValue(mockResult);
expect(await client.isSecretStored("mysecret")).toBe(mockResult);
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("mysecret");
});
it("getDefaultSecretStorageKeyId", async () => {
mockSecretStorage.getDefaultKeyId.mockResolvedValue("bzz");
expect(await client.getDefaultSecretStorageKeyId()).toEqual("bzz");
});
it("isKeyBackupKeyStored", async () => {
mockSecretStorage.isStored.mockResolvedValue(null);
expect(await client.isKeyBackupKeyStored()).toBe(null);
@ -3230,60 +3194,6 @@ describe("MatrixClient", function () {
});
});
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
describe("Crypto wrappers", () => {
describe("exception if no crypto", () => {
it("isCrossSigningReady", () => {
expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled");
});
it("bootstrapCrossSigning", () => {
expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled");
});
it("isSecretStorageReady", () => {
expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled");
});
});
describe("defer to crypto backend", () => {
let mockCryptoBackend: Mocked<CryptoBackend>;
beforeEach(() => {
mockCryptoBackend = {
isCrossSigningReady: jest.fn(),
bootstrapCrossSigning: jest.fn(),
isSecretStorageReady: jest.fn(),
stop: jest.fn().mockResolvedValue(undefined),
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockCryptoBackend;
});
it("isCrossSigningReady", async () => {
const testResult = "test";
mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isCrossSigningReady()).toBe(testResult);
expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1);
});
it("bootstrapCrossSigning", async () => {
const testOpts = {};
mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined);
await client.bootstrapCrossSigning(testOpts);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts);
});
it("isSecretStorageReady", async () => {
client["cryptoBackend"] = mockCryptoBackend;
const testResult = "test";
mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isSecretStorageReady()).toBe(testResult);
expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1);
});
});
});
describe("paginateEventTimeline()", () => {
describe("notifications timeline", () => {
const unsafeNotification = {

View File

@ -18,7 +18,6 @@ import { type MockedObject } from "jest-mock";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import { type Crypto, type IEventDecryptionResult } from "../../../src/crypto";
import {
type IAnnotatedPushRule,
type MatrixClient,
@ -28,7 +27,11 @@ import {
TweakName,
} from "../../../src";
import { DecryptionFailureCode } from "../../../src/crypto-api";
import { DecryptionError } from "../../../src/common-crypto/CryptoBackend";
import {
type CryptoBackend,
DecryptionError,
type EventDecryptionResult,
} from "../../../src/common-crypto/CryptoBackend";
describe("MatrixEvent", () => {
it("should create copies of itself", () => {
@ -369,7 +372,7 @@ describe("MatrixEvent", () => {
const testError = new Error("test error");
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(testError),
} as unknown as Crypto;
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
@ -391,7 +394,7 @@ describe("MatrixEvent", () => {
const testError = new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, "uisi");
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(testError),
} as unknown as Crypto;
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
@ -418,7 +421,7 @@ describe("MatrixEvent", () => {
"The sender has disabled encrypting to unverified devices.",
),
),
} as unknown as Crypto;
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
@ -453,7 +456,7 @@ describe("MatrixEvent", () => {
},
});
}),
} as unknown as Crypto;
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(crypto);
@ -478,7 +481,7 @@ describe("MatrixEvent", () => {
const crypto = {
decryptEvent: jest.fn().mockImplementationOnce(() => {
return Promise.resolve<IEventDecryptionResult>({
return Promise.resolve<EventDecryptionResult>({
clearEvent: {
type: "m.room.message",
content: {
@ -491,7 +494,7 @@ describe("MatrixEvent", () => {
},
});
}),
} as unknown as Crypto;
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.getType()).toEqual("m.room.message");

View File

@ -53,12 +53,12 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient";
import { ReceiptType, type WrappedReceipt } from "../../src/@types/read_receipts";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { type Crypto } from "../../src/crypto";
import * as threadUtils from "../test-utils/thread";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
import { logger } from "../../src/logger";
import { flushPromises } from "../test-utils/flushPromises";
import { KnownMembership } from "../../src/@types/membership";
import type { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
describe("Room", function () {
const roomId = "!foo:bar";
@ -3774,9 +3774,9 @@ describe("Room", function () {
it("should load pending events from from the store and decrypt if needed", async () => {
const client = new TestClient(userA).client;
client.crypto = client["cryptoBackend"] = {
client["cryptoBackend"] = {
decryptEvent: jest.fn().mockResolvedValue({ clearEvent: { body: "enc" } }),
} as unknown as Crypto;
} as unknown as CryptoBackend;
client.store.getPendingEvents = jest.fn(async (roomId) => [
{
event_id: "$1:server",

View File

@ -430,13 +430,18 @@ describe("initRustCrypto", () => {
expect(session.senderSigningKey).toBe(undefined);
}, 10000);
async function encryptAndStoreSecretKey(type: string, key: Uint8Array, pickleKey: string, store: CryptoStore) {
async function encryptAndStoreSecretKey(
type: string,
key: Uint8Array,
pickleKey: string,
store: MemoryCryptoStore,
) {
const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), Buffer.from(pickleKey), type);
store.storeSecretStorePrivateKey(undefined, type as keyof SecretStorePrivateKeys, encryptedKey);
}
/** Create a bunch of fake Olm sessions and stash them in the DB. */
function createSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
function createSessions(store: MemoryCryptoStore, nDevices: number, nSessionsPerDevice: number) {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const sessionData = {
@ -451,7 +456,7 @@ describe("initRustCrypto", () => {
}
/** Create a bunch of fake Megolm sessions and stash them in the DB. */
function createMegolmSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
function createMegolmSessions(store: MemoryCryptoStore, nDevices: number, nSessionsPerDevice: number) {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndInboundGroupSession(
@ -1009,34 +1014,6 @@ describe("RustCrypto", () => {
});
});
describe(".getEventEncryptionInfo", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto();
});
it("should handle unencrypted events", () => {
const event = mkEvent({ event: true, type: "m.room.message", content: { body: "xyz" } });
const res = rustCrypto.getEventEncryptionInfo(event);
expect(res.encrypted).toBeFalsy();
});
it("should handle encrypted events", async () => {
const event = mkEvent({ event: true, type: "m.room.encrypted", content: { algorithm: "fake_alg" } });
const mockCryptoBackend = {
decryptEvent: () =>
({
senderCurve25519Key: "1234",
}) as IEventDecryptionResult,
} as unknown as CryptoBackend;
await event.attemptDecryption(mockCryptoBackend);
const res = rustCrypto.getEventEncryptionInfo(event);
expect(res.encrypted).toBeTruthy();
});
});
describe(".getEncryptionInfoForEvent", () => {
let rustCrypto: RustCrypto;
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;

View File

@ -48,6 +48,8 @@ import {
import { CallFeed } from "../../../src/webrtc/callFeed";
import { EventType, type IContent, type ISendEventResponse, type MatrixEvent, type Room } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import type { CryptoApi } from "../../../src/crypto-api";
import { GroupCallUnknownDeviceError } from "../../../src/webrtc/groupCall";
const FAKE_ROOM_ID = "!foo:bar";
const CALL_LIFETIME = 60000;
@ -1839,4 +1841,31 @@ describe("Call", function () {
const err = await prom;
expect(err.code).toBe(CallErrorCode.IceFailed);
});
it("should throw an error when trying to call 'placeCallWithCallFeeds' when crypto is enabled", async () => {
jest.spyOn(client.client, "getCrypto").mockReturnValue({} as unknown as CryptoApi);
call = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
opponentDeviceId: "opponent_device_id",
invitee: "invitee",
});
call.on(CallEvent.Error, jest.fn());
await expect(
call.placeCallWithCallFeeds([
new CallFeed({
client: client.client,
stream: new MockMediaStream("local_stream1", [
new MockMediaStreamTrack("track_id", "audio"),
]) as unknown as MediaStream,
userId: client.getUserId(),
deviceId: undefined,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
}),
]),
).rejects.toThrow(new GroupCallUnknownDeviceError("invitee"));
});
});

View File

@ -16,11 +16,6 @@ limitations under the License.
import type { ISignatures } from "./signed.ts";
export type OlmGroupSessionExtraData = {
untrusted?: boolean;
sharedHistory?: boolean;
};
// Backwards compatible re-export
export type { EventDecryptionResult as IEventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
@ -30,7 +25,7 @@ interface Extensible {
/* eslint-disable camelcase */
/** The result of a call to {@link MatrixClient.exportRoomKeys} */
/** The result of a call to {@link crypto-api!CryptoApi.exportRoomKeys} */
export interface IMegolmSessionData extends Extensible {
/** Sender's Curve25519 device key */
sender_key: string;

File diff suppressed because it is too large Load Diff

View File

@ -18,8 +18,6 @@ import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
import { type CrossSigningInfo, type UserTrustLevel } from "../crypto/CrossSigning.ts";
import { type IEncryptedEventInfo } from "../crypto/api.ts";
import { type KeyBackupInfo, type KeyBackupSession } from "../crypto-api/keybackup.ts";
import { type IMegolmSessionData } from "../@types/crypto.ts";
@ -45,15 +43,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
stop(): void;
/**
* Get the verification level for a given user
*
* @param userId - user to be checked
*
* @deprecated Superceded by {@link CryptoApi#getUserVerificationStatus}.
*/
checkUserTrust(userId: string): UserTrustLevel;
/**
* Encrypt an event according to the configuration of the room.
*
@ -74,35 +63,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
decryptEvent(event: MatrixEvent): Promise<EventDecryptionResult>;
/**
* Get information about the encryption of an event
*
* @param event - event to be checked
* @deprecated Use {@link CryptoApi#getEncryptionInfoForEvent} instead
*/
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
* @deprecated Prefer {@link CryptoApi#userHasCrossSigningKeys}
*/
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
/**
* Check the cross signing trust of the current user
*
* @param opts - Options object.
*
* @deprecated Unneeded for the new crypto
*/
checkOwnCrossSigningTrust(opts?: CheckOwnCrossSigningTrustOpts): Promise<void>;
/**
* Get a backup decryptor capable of decrypting megolm session data encrypted with the given backup information.
* @param backupInfo - The backup information
@ -195,13 +155,6 @@ export interface OnSyncCompletedData {
catchingUp?: boolean;
}
/**
* Options object for {@link CryptoBackend#checkOwnCrossSigningTrust}.
*/
export interface CheckOwnCrossSigningTrustOpts {
allowPrivateKeyRequests?: boolean;
}
/**
* The result of a (successful) call to {@link CryptoBackend.decryptEvent}
*/

View File

@ -283,8 +283,6 @@ export interface CryptoApi {
* @param verified - whether to mark the device as verified. Defaults to 'true'.
*
* @throws an error if the device is unknown, or has not published any encryption keys.
*
* @remarks Fires {@link matrix.CryptoEvent.DeviceVerificationChanged}
*/
setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise<void>;
@ -586,7 +584,7 @@ export interface CryptoApi {
/**
* Determine if a key backup can be trusted.
*
* @param info - key backup info dict from {@link matrix.MatrixClient.getKeyBackupVersion}.
* @param info - key backup info dict from {@link CryptoApi.getKeyBackupInfo}.
*/
isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo>;
@ -991,7 +989,7 @@ export class DeviceVerificationStatus {
* Check if we should consider this device "verified".
*
* A device is "verified" if either:
* * it has been manually marked as such via {@link matrix.MatrixClient.setDeviceVerified}.
* * it has been manually marked as such via {@link CryptoApi.setDeviceVerified}.
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
* cross-signed devices via {@link CryptoApi.setTrustCrossSignedDevices}.
*

View File

@ -35,8 +35,7 @@ export interface Aes256AuthData {
/**
* Information about a server-side key backup.
*
* Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion)
* and hence {@link matrix.MatrixClient.getKeyBackupVersion}.
* Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion).
*/
export interface KeyBackupInfo {
algorithm: string;

View File

@ -1,773 +0,0 @@
/*
Copyright 2019 - 2021 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.
*/
/**
* Cross signing methods
*/
import type { PkSigning } from "@matrix-org/olm";
import { type IObject, pkSign, pkVerify } from "./olmlib.ts";
import { logger } from "../logger.ts";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts";
import { type DeviceInfo } from "./deviceinfo.ts";
import { type ISignedKey, type MatrixClient } from "../client.ts";
import { type OlmDevice } from "./OlmDevice.ts";
import { type ICryptoCallbacks } from "./index.ts";
import { type ISignatures } from "../@types/signed.ts";
import { type CryptoStore, type SecretStorePrivateKeys } from "./store/base.ts";
import { type ServerSideSecretStorage, type SecretStorageKeyDescription } from "../secret-storage.ts";
import {
type CrossSigningKeyInfo,
DeviceVerificationStatus,
UserVerificationStatus as UserTrustLevel,
} from "../crypto-api/index.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts";
import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts";
// backwards-compatibility re-exports
export { UserTrustLevel };
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
function publicKeyFromKeyInfo(keyInfo: CrossSigningKeyInfo): string {
// `keys` is an object with { [`ed25519:${pubKey}`]: pubKey }
// We assume only a single key, and we want the bare form without type
// prefix, so we select the values.
return Object.values(keyInfo.keys)[0];
}
export interface ICacheCallbacks {
getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise<Uint8Array | null>;
storeCrossSigningKeyCache?(type: string, key?: Uint8Array): Promise<void>;
}
export interface ICrossSigningInfo {
keys: Record<string, CrossSigningKeyInfo>;
firstUse: boolean;
crossSigningVerifiedBefore: boolean;
}
export class CrossSigningInfo {
public keys: Record<string, CrossSigningKeyInfo> = {};
public firstUse = true;
// This tracks whether we've ever verified this user with any identity.
// When you verify a user, any devices online at the time that receive
// the verifying signature via the homeserver will latch this to true
// and can use it in the future to detect cases where the user has
// become unverified later for any reason.
private crossSigningVerifiedBefore = false;
/**
* Information about a user's cross-signing keys
*
* @param userId - the user that the information is about
* @param callbacks - Callbacks used to interact with the app
* Requires getCrossSigningKey and saveCrossSigningKeys
* @param cacheCallbacks - Callbacks used to interact with the cache
*/
public constructor(
public readonly userId: string,
private callbacks: ICryptoCallbacks = {},
private cacheCallbacks: ICacheCallbacks = {},
) {}
public static fromStorage(obj: ICrossSigningInfo, userId: string): CrossSigningInfo {
const res = new CrossSigningInfo(userId);
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
// @ts-ignore - ts doesn't like this and nor should we
res[prop] = obj[prop];
}
}
return res;
}
public toStorage(): ICrossSigningInfo {
return {
keys: this.keys,
firstUse: this.firstUse,
crossSigningVerifiedBefore: this.crossSigningVerifiedBefore,
};
}
/**
* Calls the app callback to ask for a private key
*
* @param type - The key type ("master", "self_signing", or "user_signing")
* @param expectedPubkey - The matching public key or undefined to use
* the stored public key for the given key type.
* @returns An array with [ public key, Olm.PkSigning ]
*/
public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> {
const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0;
if (!this.callbacks.getCrossSigningKey) {
throw new Error("No getCrossSigningKey callback supplied");
}
if (expectedPubkey === undefined) {
expectedPubkey = this.getId(type)!;
}
function validateKey(key: Uint8Array | null): [string, PkSigning] | undefined {
if (!key) return;
const signing = new globalThis.Olm.PkSigning();
const gotPubkey = signing.init_with_seed(key);
if (gotPubkey === expectedPubkey) {
return [gotPubkey, signing];
}
signing.free();
}
let privkey: Uint8Array | null = null;
if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) {
privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey);
}
const cacheresult = validateKey(privkey);
if (cacheresult) {
return cacheresult;
}
privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey);
const result = validateKey(privkey);
if (result) {
if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) {
await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey!);
}
return result;
}
/* No keysource even returned a key */
if (!privkey) {
throw new Error("getCrossSigningKey callback for " + type + " returned falsey");
}
/* We got some keys from the keysource, but none of them were valid */
throw new Error("Key type " + type + " from getCrossSigningKey callback did not match");
}
/**
* Check whether the private keys exist in secret storage.
* XXX: This could be static, be we often seem to have an instance when we
* want to know this anyway...
*
* @param secretStorage - The secret store using account data
* @returns map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted
* key
*/
public async isStoredInSecretStorage(
secretStorage: ServerSideSecretStorage,
): Promise<Record<string, object> | null> {
// check what SSSS keys have encrypted the master key (if any)
const stored = (await secretStorage.isStored("m.cross_signing.master")) || {};
// then check which of those SSSS keys have also encrypted the SSK and USK
function intersect(s: Record<string, SecretStorageKeyDescription>): void {
for (const k of Object.keys(stored)) {
if (!s[k]) {
delete stored[k];
}
}
}
for (const type of ["self_signing", "user_signing"] as const) {
intersect((await secretStorage.isStored(`m.cross_signing.${type}`)) || {});
}
return Object.keys(stored).length ? stored : null;
}
/**
* Store private keys in secret storage for use by other devices. This is
* typically called in conjunction with the creation of new cross-signing
* keys.
*
* @param keys - The keys to store
* @param secretStorage - The secret store using account data
*/
public static async storeInSecretStorage(
keys: Map<string, Uint8Array>,
secretStorage: ServerSideSecretStorage,
): Promise<void> {
for (const [type, privateKey] of keys) {
const encodedKey = encodeBase64(privateKey);
await secretStorage.store(`m.cross_signing.${type}`, encodedKey);
}
}
/**
* Get private keys from secret storage created by some other device. This
* also passes the private keys to the app-specific callback.
*
* @param type - The type of key to get. One of "master",
* "self_signing", or "user_signing".
* @param secretStorage - The secret store using account data
* @returns The private key
*/
public static async getFromSecretStorage(
type: string,
secretStorage: ServerSideSecretStorage,
): Promise<Uint8Array | null> {
const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
if (!encodedKey) {
return null;
}
return decodeBase64(encodedKey);
}
/**
* Check whether the private keys exist in the local key cache.
*
* @param type - The type of key to get. One of "master",
* "self_signing", or "user_signing". Optional, will check all by default.
* @returns True if all keys are stored in the local cache.
*/
public async isStoredInKeyCache(type?: string): Promise<boolean> {
const cacheCallbacks = this.cacheCallbacks;
if (!cacheCallbacks) return false;
const types = type ? [type] : ["master", "self_signing", "user_signing"];
for (const t of types) {
if (!(await cacheCallbacks.getCrossSigningKeyCache?.(t))) {
return false;
}
}
return true;
}
/**
* Get cross-signing private keys from the local cache.
*
* @returns A map from key type (string) to private key (Uint8Array)
*/
public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> {
const keys = new Map<string, Uint8Array>();
const cacheCallbacks = this.cacheCallbacks;
if (!cacheCallbacks) return keys;
for (const type of ["master", "self_signing", "user_signing"]) {
const privKey = await cacheCallbacks.getCrossSigningKeyCache?.(type);
if (!privKey) {
continue;
}
keys.set(type, privKey);
}
return keys;
}
/**
* Get the ID used to identify the user. This can also be used to test for
* the existence of a given key type.
*
* @param type - The type of key to get the ID of. One of "master",
* "self_signing", or "user_signing". Defaults to "master".
*
* @returns the ID
*/
public getId(type = "master"): string | null {
if (!this.keys[type]) return null;
const keyInfo = this.keys[type];
return publicKeyFromKeyInfo(keyInfo);
}
/**
* Create new cross-signing keys for the given key types. The public keys
* will be held in this class, while the private keys are passed off to the
* `saveCrossSigningKeys` application callback.
*
* @param level - The key types to reset
*/
public async resetKeys(level?: CrossSigningLevel): Promise<void> {
if (!this.callbacks.saveCrossSigningKeys) {
throw new Error("No saveCrossSigningKeys callback supplied");
}
// If we're resetting the master key, we reset all keys
if (level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master) {
level = CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING;
} else if (level === (0 as CrossSigningLevel)) {
return;
}
const privateKeys: Record<string, Uint8Array> = {};
const keys: Record<string, CrossSigningKeyInfo> = {};
let masterSigning: PkSigning | undefined;
let masterPub: string | undefined;
try {
if (level & CrossSigningLevel.MASTER) {
masterSigning = new globalThis.Olm.PkSigning();
privateKeys.master = masterSigning.generate_seed();
masterPub = masterSigning.init_with_seed(privateKeys.master);
keys.master = {
user_id: this.userId,
usage: ["master"],
keys: {
["ed25519:" + masterPub]: masterPub,
},
};
} else {
[masterPub, masterSigning] = await this.getCrossSigningKey("master");
}
if (level & CrossSigningLevel.SELF_SIGNING) {
const sskSigning = new globalThis.Olm.PkSigning();
try {
privateKeys.self_signing = sskSigning.generate_seed();
const sskPub = sskSigning.init_with_seed(privateKeys.self_signing);
keys.self_signing = {
user_id: this.userId,
usage: ["self_signing"],
keys: {
["ed25519:" + sskPub]: sskPub,
},
};
pkSign(keys.self_signing, masterSigning, this.userId, masterPub);
} finally {
sskSigning.free();
}
}
if (level & CrossSigningLevel.USER_SIGNING) {
const uskSigning = new globalThis.Olm.PkSigning();
try {
privateKeys.user_signing = uskSigning.generate_seed();
const uskPub = uskSigning.init_with_seed(privateKeys.user_signing);
keys.user_signing = {
user_id: this.userId,
usage: ["user_signing"],
keys: {
["ed25519:" + uskPub]: uskPub,
},
};
pkSign(keys.user_signing, masterSigning, this.userId, masterPub);
} finally {
uskSigning.free();
}
}
Object.assign(this.keys, keys);
this.callbacks.saveCrossSigningKeys(privateKeys);
} finally {
if (masterSigning) {
masterSigning.free();
}
}
}
/**
* unsets the keys, used when another session has reset the keys, to disable cross-signing
*/
public clearKeys(): void {
this.keys = {};
}
public setKeys(keys: Record<string, CrossSigningKeyInfo>): void {
const signingKeys: Record<string, CrossSigningKeyInfo> = {};
if (keys.master) {
if (keys.master.user_id !== this.userId) {
const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId;
logger.error(error);
throw new Error(error);
}
if (!this.keys.master) {
// this is the first key we've seen, so first-use is true
this.firstUse = true;
} else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) {
// this is a different key, so first-use is false
this.firstUse = false;
} // otherwise, same key, so no change
signingKeys.master = keys.master;
} else if (this.keys.master) {
signingKeys.master = this.keys.master;
} else {
throw new Error("Tried to set cross-signing keys without a master key");
}
const masterKey = publicKeyFromKeyInfo(signingKeys.master);
// verify signatures
if (keys.user_signing) {
if (keys.user_signing.user_id !== this.userId) {
const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId;
logger.error(error);
throw new Error(error);
}
try {
pkVerify(keys.user_signing, masterKey, this.userId);
} catch (e) {
logger.error("invalid signature on user-signing key");
// FIXME: what do we want to do here?
throw e;
}
}
if (keys.self_signing) {
if (keys.self_signing.user_id !== this.userId) {
const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId;
logger.error(error);
throw new Error(error);
}
try {
pkVerify(keys.self_signing, masterKey, this.userId);
} catch (e) {
logger.error("invalid signature on self-signing key");
// FIXME: what do we want to do here?
throw e;
}
}
// if everything checks out, then save the keys
if (keys.master) {
this.keys.master = keys.master;
// if the master key is set, then the old self-signing and user-signing keys are obsolete
delete this.keys["self_signing"];
delete this.keys["user_signing"];
}
if (keys.self_signing) {
this.keys.self_signing = keys.self_signing;
}
if (keys.user_signing) {
this.keys.user_signing = keys.user_signing;
}
}
public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void {
// It is critical that this value latches forward from false to true but
// never back to false to avoid a downgrade attack.
if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) {
this.crossSigningVerifiedBefore = true;
}
}
public async signObject<T extends object>(data: T, type: string): Promise<T & { signatures: ISignatures }> {
if (!this.keys[type]) {
throw new Error("Attempted to sign with " + type + " key but no such key present");
}
const [pubkey, signing] = await this.getCrossSigningKey(type);
try {
pkSign(data, signing, this.userId, pubkey);
return data as T & { signatures: ISignatures };
} finally {
signing.free();
}
}
public async signUser(key: CrossSigningInfo): Promise<CrossSigningKeyInfo | undefined> {
if (!this.keys.user_signing) {
logger.info("No user signing key: not signing user");
return;
}
return this.signObject(key.keys.master, "user_signing");
}
public async signDevice(userId: string, device: DeviceInfo): Promise<ISignedKey | undefined> {
if (userId !== this.userId) {
throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`);
}
if (!this.keys.self_signing) {
logger.info("No self signing key: not signing device");
return;
}
return this.signObject<Omit<ISignedKey, "signatures">>(
{
algorithms: device.algorithms,
keys: device.keys,
device_id: device.deviceId,
user_id: userId,
},
"self_signing",
);
}
/**
* Check whether a given user is trusted.
*
* @param userCrossSigning - Cross signing info for user
*
* @returns
*/
public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel {
// if we're checking our own key, then it's trusted if the master key
// and self-signing key match
if (
this.userId === userCrossSigning.userId &&
this.getId() &&
this.getId() === userCrossSigning.getId() &&
this.getId("self_signing") &&
this.getId("self_signing") === userCrossSigning.getId("self_signing")
) {
return new UserTrustLevel(true, true, this.firstUse);
}
if (!this.keys.user_signing) {
// If there's no user signing key, they can't possibly be verified.
// They may be TOFU trusted though.
return new UserTrustLevel(false, false, userCrossSigning.firstUse);
}
let userTrusted: boolean;
const userMaster = userCrossSigning.keys.master;
const uskId = this.getId("user_signing")!;
try {
pkVerify(userMaster, uskId, this.userId);
userTrusted = true;
} catch {
userTrusted = false;
}
return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse);
}
/**
* Check whether a given device is trusted.
*
* @param userCrossSigning - Cross signing info for user
* @param device - The device to check
* @param localTrust - Whether the device is trusted locally
* @param trustCrossSignedDevices - Whether we trust cross signed devices
*
* @returns
*/
public checkDeviceTrust(
userCrossSigning: CrossSigningInfo,
device: DeviceInfo,
localTrust: boolean,
trustCrossSignedDevices: boolean,
): DeviceTrustLevel {
const userTrust = this.checkUserTrust(userCrossSigning);
const userSSK = userCrossSigning.keys.self_signing;
if (!userSSK) {
// if the user has no self-signing key then we cannot make any
// trust assertions about this device from cross-signing
return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices);
}
const deviceObj = deviceToObject(device, userCrossSigning.userId);
try {
// if we can verify the user's SSK from their master key...
pkVerify(userSSK, userCrossSigning.getId()!, userCrossSigning.userId);
// ...and this device's key from their SSK...
pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId);
// ...then we trust this device as much as far as we trust the user
return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices);
} catch {
return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices);
}
}
/**
* @returns Cache callbacks
*/
public getCacheCallbacks(): ICacheCallbacks {
return this.cacheCallbacks;
}
}
interface DeviceObject extends IObject {
algorithms: string[];
keys: Record<string, string>;
device_id: string;
user_id: string;
}
function deviceToObject(device: DeviceInfo, userId: string): DeviceObject {
return {
algorithms: device.algorithms,
keys: device.keys,
device_id: device.deviceId,
user_id: userId,
signatures: device.signatures,
};
}
export enum CrossSigningLevel {
MASTER = 4,
USER_SIGNING = 2,
SELF_SIGNING = 1,
}
/**
* Represents the ways in which we trust a device.
*
* @deprecated Use {@link DeviceVerificationStatus}.
*/
export class DeviceTrustLevel extends DeviceVerificationStatus {
public constructor(
crossSigningVerified: boolean,
tofu: boolean,
localVerified: boolean,
trustCrossSignedDevices: boolean,
signedByOwner = false,
) {
super({ crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner });
}
public static fromUserTrustLevel(
userTrustLevel: UserTrustLevel,
localVerified: boolean,
trustCrossSignedDevices: boolean,
): DeviceTrustLevel {
return new DeviceTrustLevel(
userTrustLevel.isCrossSigningVerified(),
userTrustLevel.isTofu(),
localVerified,
trustCrossSignedDevices,
true,
);
}
/**
* @returns true if this device is verified via cross signing
*/
public isCrossSigningVerified(): boolean {
return this.crossSigningVerified;
}
/**
* @returns true if this device is verified locally
*/
public isLocallyVerified(): boolean {
return this.localVerified;
}
/**
* @returns true if this device is trusted from a user's key
* that is trusted on first use
*/
public isTofu(): boolean {
return this.tofu;
}
}
export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks {
return {
getCrossSigningKeyCache: async function (
type: keyof SecretStorePrivateKeys,
_expectedPublicKey: string,
): Promise<Uint8Array> {
const key = await new Promise<any>((resolve) => {
store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.getSecretStorePrivateKey(txn, resolve, type);
});
});
if (key && key.ciphertext) {
const pickleKey = Buffer.from(olmDevice.pickleKey);
const decrypted = await decryptAESSecretStorageItem(key, pickleKey, type);
return decodeBase64(decrypted);
} else {
return key;
}
},
storeCrossSigningKeyCache: async function (
type: keyof SecretStorePrivateKeys,
key?: Uint8Array,
): Promise<void> {
if (!(key instanceof Uint8Array)) {
throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`);
}
const pickleKey = Buffer.from(olmDevice.pickleKey);
const encryptedKey = await encryptAESSecretStorageItem(encodeBase64(key), pickleKey, type);
return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.storeSecretStorePrivateKey(txn, type, encryptedKey);
});
},
};
}
export type KeysDuringVerification = [[string, PkSigning], [string, PkSigning], [string, PkSigning], void];
/**
* Request cross-signing keys from another device during verification.
*
* @param baseApis - base Matrix API interface
* @param userId - The user ID being verified
* @param deviceId - The device ID being verified
*/
export async function requestKeysDuringVerification(
baseApis: MatrixClient,
userId: string,
deviceId: string,
): Promise<KeysDuringVerification | void> {
// If this is a self-verification, ask the other party for keys
if (baseApis.getUserId() !== userId) {
return;
}
logger.log("Cross-signing: Self-verification done; requesting keys");
// This happens asynchronously, and we're not concerned about waiting for
// it. We return here in order to test.
return new Promise<KeysDuringVerification | void>((resolve, reject) => {
const client = baseApis;
const original = client.crypto!.crossSigningInfo;
// We already have all of the infrastructure we need to validate and
// cache cross-signing keys, so instead of replicating that, here we set
// up callbacks that request them from the other device and call
// CrossSigningInfo.getCrossSigningKey() to validate/cache
const crossSigning = new CrossSigningInfo(
original.userId,
{
getCrossSigningKey: async (type): Promise<Uint8Array> => {
logger.debug("Cross-signing: requesting secret", type, deviceId);
const { promise } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]);
const result = await promise;
const decoded = decodeBase64(result);
return Uint8Array.from(decoded);
},
},
original.getCacheCallbacks(),
);
crossSigning.keys = original.keys;
// XXX: get all keys out if we get one key out
// https://github.com/vector-im/element-web/issues/12604
// then change here to reject on the timeout
// Requests can be ignored, so don't wait around forever
const timeout = new Promise<void>((resolve) => {
setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout"));
});
// also request and cache the key backup key
const backupKeyPromise = (async (): Promise<void> => {
const cachedKey = await client.crypto!.getSessionBackupPrivateKey();
if (!cachedKey) {
logger.info("No cached backup key found. Requesting...");
const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]);
const base64Key = await secretReq.promise;
logger.info("Got key backup key, decoding...");
const decodedKey = decodeBase64(base64Key);
logger.info("Decoded backup key, storing...");
await client.crypto!.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey));
logger.info("Backup key stored. Starting backup restore...");
const backupInfo = await client.getKeyBackupVersion();
// no need to await for this - just let it go in the bg
client.restoreKeyBackupWithCache(undefined, undefined, backupInfo!).then(() => {
logger.info("Backup restored.");
});
}
})();
// We call getCrossSigningKey() for its side-effects
Promise.race<KeysDuringVerification | void>([
Promise.all([
crossSigning.getCrossSigningKey("master"),
crossSigning.getCrossSigningKey("self_signing"),
crossSigning.getCrossSigningKey("user_signing"),
backupKeyPromise,
]) as Promise<KeysDuringVerification>,
timeout,
]).then(resolve, reject);
}).catch((e) => {
logger.warn("Cross-signing: failure while requesting keys:", e);
});
}

View File

@ -1,989 +0,0 @@
/*
Copyright 2017 - 2021 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.
*/
/**
* Manages the list of other users' devices
*/
import { logger } from "../logger.ts";
import { DeviceInfo, type IDevice } from "./deviceinfo.ts";
import { CrossSigningInfo, type ICrossSigningInfo } from "./CrossSigning.ts";
import * as olmlib from "./olmlib.ts";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts";
import { chunkPromises, defer, type IDeferred, sleep } from "../utils.ts";
import { type DeviceKeys, type IDownloadKeyResult, type Keys, type MatrixClient, type SigningKeys } from "../client.ts";
import { type OlmDevice } from "./OlmDevice.ts";
import { type CryptoStore } from "./store/base.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { CryptoEvent, type CryptoEventHandlerMap } from "./index.ts";
/* State transition diagram for DeviceList.deviceTrackingStatus
*
* |
* stopTrackingDeviceList V
* +---------------------> NOT_TRACKED
* | |
* +<--------------------+ | startTrackingDeviceList
* | | V
* | +-------------> PENDING_DOWNLOAD <--------------------+-+
* | | ^ | | |
* | | restart download | | start download | | invalidateUserDeviceList
* | | client failed | | | |
* | | | V | |
* | +------------ DOWNLOAD_IN_PROGRESS -------------------+ |
* | | | |
* +<-------------------+ | download successful |
* ^ V |
* +----------------------- UP_TO_DATE ------------------------+
*/
// constants for DeviceList.deviceTrackingStatus
export enum TrackingStatus {
NotTracked,
PendingDownload,
DownloadInProgress,
UpToDate,
}
// user-Id → device-Id → DeviceInfo
export type DeviceInfoMap = Map<string, Map<string, DeviceInfo>>;
type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated;
export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHandlerMap> {
private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {};
public crossSigningInfo: { [userId: string]: ICrossSigningInfo } = {};
// map of identity keys to the user who owns it
private userByIdentityKey: Record<string, string> = {};
// which users we are tracking device status for.
private deviceTrackingStatus: { [userId: string]: TrackingStatus } = {}; // loaded from storage in load()
// The 'next_batch' sync token at the point the data was written,
// ie. a token representing the point immediately after the
// moment represented by the snapshot in the db.
private syncToken: string | null = null;
private keyDownloadsInProgressByUser = new Map<string, Promise<void>>();
// Set whenever changes are made other than setting the sync token
private dirty = false;
// Promise resolved when device data is saved
private savePromise: Promise<boolean> | null = null;
// Function that resolves the save promise
private resolveSavePromise: ((saved: boolean) => void) | null = null;
// The time the save is scheduled for
private savePromiseTime: number | null = null;
// The timer used to delay the save
private saveTimer: ReturnType<typeof setTimeout> | null = null;
// True if we have fetched data from the server or loaded a non-empty
// set of device data from the store
private hasFetched: boolean | null = null;
private readonly serialiser: DeviceListUpdateSerialiser;
public constructor(
baseApis: MatrixClient,
private readonly cryptoStore: CryptoStore,
olmDevice: OlmDevice,
// Maximum number of user IDs per request to prevent server overload (#1619)
public readonly keyDownloadChunkSize = 250,
) {
super();
this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this);
}
/**
* Load the device tracking state from storage
*/
public async load(): Promise<void> {
await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
this.hasFetched = Boolean(deviceData?.devices);
this.devices = deviceData ? deviceData.devices : {};
this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {};
this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
this.syncToken = deviceData?.syncToken ?? null;
this.userByIdentityKey = {};
for (const user of Object.keys(this.devices)) {
const userDevices = this.devices[user];
for (const device of Object.keys(userDevices)) {
const idKey = userDevices[device].keys["curve25519:" + device];
if (idKey !== undefined) {
this.userByIdentityKey[idKey] = user;
}
}
}
});
});
for (const u of Object.keys(this.deviceTrackingStatus)) {
// if a download was in progress when we got shut down, it isn't any more.
if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) {
this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload;
}
}
}
public stop(): void {
if (this.saveTimer !== null) {
clearTimeout(this.saveTimer);
}
}
/**
* Save the device tracking state to storage, if any changes are
* pending other than updating the sync token
*
* The actual save will be delayed by a short amount of time to
* aggregate multiple writes to the database.
*
* @param delay - Time in ms before which the save actually happens.
* By default, the save is delayed for a short period in order to batch
* multiple writes, but this behaviour can be disabled by passing 0.
*
* @returns true if the data was saved, false if
* it was not (eg. because no changes were pending). The promise
* will only resolve once the data is saved, so may take some time
* to resolve.
*/
public async saveIfDirty(delay = 500): Promise<boolean> {
if (!this.dirty) return Promise.resolve(false);
// Delay saves for a bit so we can aggregate multiple saves that happen
// in quick succession (eg. when a whole room's devices are marked as known)
const targetTime = Date.now() + delay;
if (this.savePromiseTime && targetTime < this.savePromiseTime) {
// There's a save scheduled but for after we would like: cancel
// it & schedule one for the time we want
clearTimeout(this.saveTimer!);
this.saveTimer = null;
this.savePromiseTime = null;
// (but keep the save promise since whatever called save before
// will still want to know when the save is done)
}
let savePromise = this.savePromise;
if (savePromise === null) {
savePromise = new Promise((resolve) => {
this.resolveSavePromise = resolve;
});
this.savePromise = savePromise;
}
if (this.saveTimer === null) {
const resolveSavePromise = this.resolveSavePromise;
this.savePromiseTime = targetTime;
this.saveTimer = setTimeout(() => {
logger.log("Saving device tracking data", this.syncToken);
// null out savePromise now (after the delay but before the write),
// otherwise we could return the existing promise when the save has
// actually already happened.
this.savePromiseTime = null;
this.saveTimer = null;
this.savePromise = null;
this.resolveSavePromise = null;
this.cryptoStore
.doTxn("readwrite", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
this.cryptoStore.storeEndToEndDeviceData(
{
devices: this.devices,
crossSigningInfo: this.crossSigningInfo,
trackingStatus: this.deviceTrackingStatus,
syncToken: this.syncToken ?? undefined,
},
txn,
);
})
.then(
() => {
// The device list is considered dirty until the write completes.
this.dirty = false;
resolveSavePromise?.(true);
},
(err) => {
logger.error("Failed to save device tracking data", this.syncToken);
logger.error(err);
},
);
}, delay);
}
return savePromise;
}
/**
* Gets the sync token last set with setSyncToken
*
* @returns The sync token
*/
public getSyncToken(): string | null {
return this.syncToken;
}
/**
* Sets the sync token that the app will pass as the 'since' to the /sync
* endpoint next time it syncs.
* The sync token must always be set after any changes made as a result of
* data in that sync since setting the sync token to a newer one will mean
* those changed will not be synced from the server if a new client starts
* up with that data.
*
* @param st - The sync token
*/
public setSyncToken(st: string | null): void {
this.syncToken = st;
}
/**
* Ensures up to date keys for a list of users are stored in the session store,
* downloading and storing them if they're not (or if forceDownload is
* true).
* @param userIds - The users to fetch.
* @param forceDownload - Always download the keys even if cached.
*
* @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}.
*/
public downloadKeys(userIds: string[], forceDownload: boolean): Promise<DeviceInfoMap> {
const usersToDownload: string[] = [];
const promises: Promise<unknown>[] = [];
userIds.forEach((u) => {
const trackingStatus = this.deviceTrackingStatus[u];
if (this.keyDownloadsInProgressByUser.has(u)) {
// already a key download in progress/queued for this user; its results
// will be good enough for us.
logger.log(`downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`);
promises.push(this.keyDownloadsInProgressByUser.get(u)!);
} else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) {
usersToDownload.push(u);
}
});
if (usersToDownload.length != 0) {
logger.log("downloadKeys: downloading for", usersToDownload);
const downloadPromise = this.doKeyDownload(usersToDownload);
promises.push(downloadPromise);
}
if (promises.length === 0) {
logger.log("downloadKeys: already have all necessary keys");
}
return Promise.all(promises).then(() => {
return this.getDevicesFromStore(userIds);
});
}
/**
* Get the stored device keys for a list of user ids
*
* @param userIds - the list of users to list keys for.
*
* @returns userId-\>deviceId-\>{@link DeviceInfo}.
*/
private getDevicesFromStore(userIds: string[]): DeviceInfoMap {
const stored: DeviceInfoMap = new Map();
userIds.forEach((userId) => {
const deviceMap = new Map();
this.getStoredDevicesForUser(userId)?.forEach(function (device) {
deviceMap.set(device.deviceId, device);
});
stored.set(userId, deviceMap);
});
return stored;
}
/**
* Returns a list of all user IDs the DeviceList knows about
*
* @returns All known user IDs
*/
public getKnownUserIds(): string[] {
return Object.keys(this.devices);
}
/**
* Get the stored device keys for a user id
*
* @param userId - the user to list keys for.
*
* @returns list of devices, or null if we haven't
* managed to get a list of devices for this user yet.
*/
public getStoredDevicesForUser(userId: string): DeviceInfo[] | null {
const devs = this.devices[userId];
if (!devs) {
return null;
}
const res: DeviceInfo[] = [];
for (const deviceId in devs) {
if (devs.hasOwnProperty(deviceId)) {
res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId));
}
}
return res;
}
/**
* Get the stored device data for a user, in raw object form
*
* @param userId - the user to get data for
*
* @returns `deviceId->{object}` devices, or undefined if
* there is no data for this user.
*/
public getRawStoredDevicesForUser(userId: string): Record<string, IDevice> {
return this.devices[userId];
}
public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
if (!this.crossSigningInfo[userId]) return null;
return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId);
}
public storeCrossSigningForUser(userId: string, info: ICrossSigningInfo): void {
this.crossSigningInfo[userId] = info;
this.dirty = true;
}
/**
* Get the stored keys for a single device
*
*
* @returns device, or undefined
* if we don't know about this device
*/
public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined {
const devs = this.devices[userId];
if (!devs?.[deviceId]) {
return undefined;
}
return DeviceInfo.fromStorage(devs[deviceId], deviceId);
}
/**
* Get a user ID by one of their device's curve25519 identity key
*
* @param algorithm - encryption algorithm
* @param senderKey - curve25519 key to match
*
* @returns user ID
*/
public getUserByIdentityKey(algorithm: string, senderKey: string): string | null {
if (algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM) {
// we only deal in olm keys
return null;
}
return this.userByIdentityKey[senderKey];
}
/**
* Find a device by curve25519 identity key
*
* @param algorithm - encryption algorithm
* @param senderKey - curve25519 key to match
*/
public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null {
const userId = this.getUserByIdentityKey(algorithm, senderKey);
if (!userId) {
return null;
}
const devices = this.devices[userId];
if (!devices) {
return null;
}
for (const deviceId in devices) {
if (!devices.hasOwnProperty(deviceId)) {
continue;
}
const device = devices[deviceId];
for (const keyId in device.keys) {
if (!device.keys.hasOwnProperty(keyId)) {
continue;
}
if (keyId.indexOf("curve25519:") !== 0) {
continue;
}
const deviceKey = device.keys[keyId];
if (deviceKey == senderKey) {
return DeviceInfo.fromStorage(device, deviceId);
}
}
}
// doesn't match a known device
return null;
}
/**
* Replaces the list of devices for a user with the given device list
*
* @param userId - The user ID
* @param devices - New device info for user
*/
public storeDevicesForUser(userId: string, devices: Record<string, IDevice>): void {
this.setRawStoredDevicesForUser(userId, devices);
this.dirty = true;
}
/**
* flag the given user for device-list tracking, if they are not already.
*
* This will mean that a subsequent call to refreshOutdatedDeviceLists()
* will download the device list for the user, and that subsequent calls to
* invalidateUserDeviceList will trigger more updates.
*
*/
public startTrackingDeviceList(userId: string): void {
// sanity-check the userId. This is mostly paranoia, but if synapse
// can't parse the userId we give it as an mxid, it 500s the whole
// request and we can never update the device lists again (because
// the broken userId is always 'invalid' and always included in any
// refresh request).
// By checking it is at least a string, we can eliminate a class of
// silly errors.
if (typeof userId !== "string") {
throw new Error("userId must be a string; was " + userId);
}
if (!this.deviceTrackingStatus[userId]) {
logger.log("Now tracking device list for " + userId);
this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload;
// we don't yet persist the tracking status, since there may be a lot
// of calls; we save all data together once the sync is done
this.dirty = true;
}
}
/**
* Mark the given user as no longer being tracked for device-list updates.
*
* This won't affect any in-progress downloads, which will still go on to
* complete; it will just mean that we don't think that we have an up-to-date
* list for future calls to downloadKeys.
*
*/
public stopTrackingDeviceList(userId: string): void {
if (this.deviceTrackingStatus[userId]) {
logger.log("No longer tracking device list for " + userId);
this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked;
// we don't yet persist the tracking status, since there may be a lot
// of calls; we save all data together once the sync is done
this.dirty = true;
}
}
/**
* Set all users we're currently tracking to untracked
*
* This will flag each user whose devices we are tracking as in need of an
* update.
*/
public stopTrackingAllDeviceLists(): void {
for (const userId of Object.keys(this.deviceTrackingStatus)) {
this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked;
}
this.dirty = true;
}
/**
* Mark the cached device list for the given user outdated.
*
* If we are not tracking this user's devices, we'll do nothing. Otherwise
* we flag the user as needing an update.
*
* This doesn't actually set off an update, so that several users can be
* batched together. Call refreshOutdatedDeviceLists() for that.
*
*/
public invalidateUserDeviceList(userId: string): void {
if (this.deviceTrackingStatus[userId]) {
logger.log("Marking device list outdated for", userId);
this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload;
// we don't yet persist the tracking status, since there may be a lot
// of calls; we save all data together once the sync is done
this.dirty = true;
}
}
/**
* If we have users who have outdated device lists, start key downloads for them
*
* @returns which completes when the download completes; normally there
* is no need to wait for this (it's mostly for the unit tests).
*/
public refreshOutdatedDeviceLists(): Promise<void> {
this.saveIfDirty();
const usersToDownload: string[] = [];
for (const userId of Object.keys(this.deviceTrackingStatus)) {
const stat = this.deviceTrackingStatus[userId];
if (stat == TrackingStatus.PendingDownload) {
usersToDownload.push(userId);
}
}
return this.doKeyDownload(usersToDownload);
}
/**
* Set the stored device data for a user, in raw object form
* Used only by internal class DeviceListUpdateSerialiser
*
* @param userId - the user to get data for
*
* @param devices - `deviceId->{object}` the new devices
*/
public setRawStoredDevicesForUser(userId: string, devices: Record<string, IDevice>): void {
// remove old devices from userByIdentityKey
if (this.devices[userId] !== undefined) {
for (const [deviceId, dev] of Object.entries(this.devices[userId])) {
const identityKey = dev.keys["curve25519:" + deviceId];
delete this.userByIdentityKey[identityKey];
}
}
this.devices[userId] = devices;
// add new devices into userByIdentityKey
for (const [deviceId, dev] of Object.entries(devices)) {
const identityKey = dev.keys["curve25519:" + deviceId];
this.userByIdentityKey[identityKey] = userId;
}
}
public setRawStoredCrossSigningForUser(userId: string, info: ICrossSigningInfo): void {
this.crossSigningInfo[userId] = info;
}
/**
* Fire off download update requests for the given users, and update the
* device list tracking status for them, and the
* keyDownloadsInProgressByUser map for them.
*
* @param users - list of userIds
*
* @returns resolves when all the users listed have
* been updated. rejects if there was a problem updating any of the
* users.
*/
private doKeyDownload(users: string[]): Promise<void> {
if (users.length === 0) {
// nothing to do
return Promise.resolve();
}
const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken!).then(
() => {
finished(true);
},
(e) => {
logger.error("Error downloading keys for " + users + ":", e);
finished(false);
throw e;
},
);
users.forEach((u) => {
this.keyDownloadsInProgressByUser.set(u, prom);
const stat = this.deviceTrackingStatus[u];
if (stat == TrackingStatus.PendingDownload) {
this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress;
}
});
const finished = (success: boolean): void => {
this.emit(CryptoEvent.WillUpdateDevices, users, !this.hasFetched);
users.forEach((u) => {
this.dirty = true;
// we may have queued up another download request for this user
// since we started this request. If that happens, we should
// ignore the completion of the first one.
if (this.keyDownloadsInProgressByUser.get(u) !== prom) {
logger.log("Another update in the queue for", u, "- not marking up-to-date");
return;
}
this.keyDownloadsInProgressByUser.delete(u);
const stat = this.deviceTrackingStatus[u];
if (stat == TrackingStatus.DownloadInProgress) {
if (success) {
// we didn't get any new invalidations since this download started:
// this user's device list is now up to date.
this.deviceTrackingStatus[u] = TrackingStatus.UpToDate;
logger.log("Device list for", u, "now up to date");
} else {
this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload;
}
}
});
this.saveIfDirty();
this.emit(CryptoEvent.DevicesUpdated, users, !this.hasFetched);
this.hasFetched = true;
};
return prom;
}
}
/**
* Serialises updates to device lists
*
* Ensures that results from /keys/query are not overwritten if a second call
* completes *before* an earlier one.
*
* It currently does this by ensuring only one call to /keys/query happens at a
* time (and queuing other requests up).
*/
class DeviceListUpdateSerialiser {
private downloadInProgress = false;
// users which are queued for download
// userId -> true
private keyDownloadsQueuedByUser: Record<string, boolean> = {};
// deferred which is resolved when the queued users are downloaded.
// non-null indicates that we have users queued for download.
private queuedQueryDeferred?: IDeferred<void>;
private syncToken?: string; // The sync token we send with the requests
/*
* @param baseApis - Base API object
* @param olmDevice - The Olm Device
* @param deviceList - The device list object, the device list to be updated
*/
public constructor(
private readonly baseApis: MatrixClient,
private readonly olmDevice: OlmDevice,
private readonly deviceList: DeviceList,
) {}
/**
* Make a key query request for the given users
*
* @param users - list of user ids
*
* @param syncToken - sync token to pass in the query request, to
* help the HS give the most recent results
*
* @returns resolves when all the users listed have
* been updated. rejects if there was a problem updating any of the
* users.
*/
public updateDevicesForUsers(users: string[], syncToken: string): Promise<void> {
users.forEach((u) => {
this.keyDownloadsQueuedByUser[u] = true;
});
if (!this.queuedQueryDeferred) {
this.queuedQueryDeferred = defer();
}
// We always take the new sync token and just use the latest one we've
// been given, since it just needs to be at least as recent as the
// sync response the device invalidation message arrived in
this.syncToken = syncToken;
if (this.downloadInProgress) {
// just queue up these users
logger.log("Queued key download for", users);
return this.queuedQueryDeferred.promise;
}
// start a new download.
return this.doQueuedQueries();
}
private doQueuedQueries(): Promise<void> {
if (this.downloadInProgress) {
throw new Error("DeviceListUpdateSerialiser.doQueuedQueries called with request active");
}
const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser);
this.keyDownloadsQueuedByUser = {};
const deferred = this.queuedQueryDeferred;
this.queuedQueryDeferred = undefined;
logger.log("Starting key download for", downloadUsers);
this.downloadInProgress = true;
const opts: Parameters<MatrixClient["downloadKeysForUsers"]>[1] = {};
if (this.syncToken) {
opts.token = this.syncToken;
}
const factories: Array<() => Promise<IDownloadKeyResult>> = [];
for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) {
const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize);
factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts));
}
chunkPromises(factories, 3)
.then(async (responses: IDownloadKeyResult[]) => {
const dk: IDownloadKeyResult["device_keys"] = Object.assign(
{},
...responses.map((res) => res.device_keys || {}),
);
const masterKeys: IDownloadKeyResult["master_keys"] = Object.assign(
{},
...responses.map((res) => res.master_keys || {}),
);
const ssks: IDownloadKeyResult["self_signing_keys"] = Object.assign(
{},
...responses.map((res) => res.self_signing_keys || {}),
);
const usks: IDownloadKeyResult["user_signing_keys"] = Object.assign(
{},
...responses.map((res) => res.user_signing_keys || {}),
);
// yield to other things that want to execute in between users, to
// avoid wedging the CPU
// (https://github.com/vector-im/element-web/issues/3158)
//
// of course we ought to do this in a web worker or similar, but
// this serves as an easy solution for now.
for (const userId of downloadUsers) {
await sleep(5);
try {
await this.processQueryResponseForUser(userId, dk[userId], {
master: masterKeys?.[userId],
self_signing: ssks?.[userId],
user_signing: usks?.[userId],
});
} catch (e) {
// log the error but continue, so that one bad key
// doesn't kill the whole process
logger.error(`Error processing keys for ${userId}:`, e);
}
}
})
.then(
() => {
logger.log("Completed key download for " + downloadUsers);
this.downloadInProgress = false;
deferred?.resolve();
// if we have queued users, fire off another request.
if (this.queuedQueryDeferred) {
this.doQueuedQueries();
}
},
(e) => {
logger.warn("Error downloading keys for " + downloadUsers + ":", e);
this.downloadInProgress = false;
deferred?.reject(e);
},
);
return deferred!.promise;
}
private async processQueryResponseForUser(
userId: string,
dkResponse: DeviceKeys,
crossSigningResponse: {
master?: Keys;
self_signing?: SigningKeys;
user_signing?: SigningKeys;
},
): Promise<void> {
logger.log("got device keys for " + userId + ":", dkResponse);
logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse);
{
// map from deviceid -> deviceinfo for this user
const userStore: Record<string, DeviceInfo> = {};
const devs = this.deviceList.getRawStoredDevicesForUser(userId);
if (devs) {
Object.keys(devs).forEach((deviceId) => {
const d = DeviceInfo.fromStorage(devs[deviceId], deviceId);
userStore[deviceId] = d;
});
}
await updateStoredDeviceKeysForUser(
this.olmDevice,
userId,
userStore,
dkResponse || {},
this.baseApis.getUserId()!,
this.baseApis.deviceId!,
);
// put the updates into the object that will be returned as our results
const storage: Record<string, IDevice> = {};
Object.keys(userStore).forEach((deviceId) => {
storage[deviceId] = userStore[deviceId].toStorage();
});
this.deviceList.setRawStoredDevicesForUser(userId, storage);
}
// now do the same for the cross-signing keys
{
// FIXME: should we be ignoring empty cross-signing responses, or
// should we be dropping the keys?
if (
crossSigningResponse &&
(crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing)
) {
const crossSigning =
this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId);
crossSigning.setKeys(crossSigningResponse);
this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage());
// NB. Unlike most events in the js-sdk, this one is internal to the
// js-sdk and is not re-emitted
this.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, userId);
}
}
}
}
async function updateStoredDeviceKeysForUser(
olmDevice: OlmDevice,
userId: string,
userStore: Record<string, DeviceInfo>,
userResult: IDownloadKeyResult["device_keys"]["user_id"],
localUserId: string,
localDeviceId: string,
): Promise<boolean> {
let updated = false;
// remove any devices in the store which aren't in the response
for (const deviceId in userStore) {
if (!userStore.hasOwnProperty(deviceId)) {
continue;
}
if (!(deviceId in userResult)) {
if (userId === localUserId && deviceId === localDeviceId) {
logger.warn(`Local device ${deviceId} missing from sync, skipping removal`);
continue;
}
logger.log("Device " + userId + ":" + deviceId + " has been removed");
delete userStore[deviceId];
updated = true;
}
}
for (const deviceId in userResult) {
if (!userResult.hasOwnProperty(deviceId)) {
continue;
}
const deviceResult = userResult[deviceId];
// check that the user_id and device_id in the response object are
// correct
if (deviceResult.user_id !== userId) {
logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId);
continue;
}
if (deviceResult.device_id !== deviceId) {
logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId);
continue;
}
if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) {
updated = true;
}
}
return updated;
}
/*
* Process a device in a /query response, and add it to the userStore
*
* returns (a promise for) true if a change was made, else false
*/
async function storeDeviceKeys(
olmDevice: OlmDevice,
userStore: Record<string, DeviceInfo>,
deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"],
): Promise<boolean> {
if (!deviceResult.keys) {
// no keys?
return false;
}
const deviceId = deviceResult.device_id;
const userId = deviceResult.user_id;
const signKeyId = "ed25519:" + deviceId;
const signKey = deviceResult.keys[signKeyId];
if (!signKey) {
logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key");
return false;
}
const unsigned = deviceResult.unsigned || {};
const signatures = deviceResult.signatures || {};
try {
await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey);
} catch (e) {
logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e);
return false;
}
// DeviceInfo
let deviceStore;
if (deviceId in userStore) {
// already have this device.
deviceStore = userStore[deviceId];
if (deviceStore.getFingerprint() != signKey) {
// this should only happen if the list has been MITMed; we are
// best off sticking with the original keys.
//
// Should we warn the user about it somehow?
logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed");
return false;
}
} else {
userStore[deviceId] = deviceStore = new DeviceInfo(deviceId);
}
deviceStore.keys = deviceResult.keys || {};
deviceStore.algorithms = deviceResult.algorithms || [];
deviceStore.unsigned = unsigned;
deviceStore.signatures = signatures;
return true;
}

View File

@ -1,358 +0,0 @@
/*
Copyright 2021 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 { logger } from "../logger.ts";
import { MatrixEvent } from "../models/event.ts";
import { createCryptoStoreCacheCallbacks, type ICacheCallbacks } from "./CrossSigning.ts";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts";
import { Method, ClientPrefix } from "../http-api/index.ts";
import { type Crypto, type ICryptoCallbacks } from "./index.ts";
import {
ClientEvent,
type ClientEventHandlerMap,
type CrossSigningKeys,
type ISignedKey,
type KeySignatures,
} from "../client.ts";
import { type IKeyBackupInfo } from "./keybackup.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type AccountDataClient, type SecretStorageKeyDescription } from "../secret-storage.ts";
import { type BootstrapCrossSigningOpts, type CrossSigningKeyInfo } from "../crypto-api/index.ts";
import { type AccountDataEvents } from "../@types/event.ts";
import { type EmptyObject } from "../@types/common.ts";
interface ICrossSigningKeys {
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
keys: Record<"master" | "self_signing" | "user_signing", CrossSigningKeyInfo>;
}
/**
* Builds an EncryptionSetupOperation by calling any of the add.. methods.
* Once done, `buildOperation()` can be called which allows to apply to operation.
*
* This is used as a helper by Crypto to keep track of all the network requests
* and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future)
* Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them
* more than once.
*/
export class EncryptionSetupBuilder {
public readonly accountDataClientAdapter: AccountDataClientAdapter;
public readonly crossSigningCallbacks: CrossSigningCallbacks;
public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks;
private crossSigningKeys?: ICrossSigningKeys;
private keySignatures?: KeySignatures;
private keyBackupInfo?: IKeyBackupInfo;
private sessionBackupPrivateKey?: Uint8Array;
/**
* @param accountData - pre-existing account data, will only be read, not written.
* @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet
*/
public constructor(accountData: Map<string, MatrixEvent>, delegateCryptoCallbacks?: ICryptoCallbacks) {
this.accountDataClientAdapter = new AccountDataClientAdapter(accountData);
this.crossSigningCallbacks = new CrossSigningCallbacks();
this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks);
}
/**
* Adds new cross-signing public keys
*
* @param authUpload - Function called to await an interactive auth
* flow when uploading device signing keys.
* Args:
* A function that makes the request requiring auth. Receives
* the auth data as an object. Can be called multiple times, first with
* an empty authDict, to obtain the flows.
* @param keys - the new keys
*/
public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void {
this.crossSigningKeys = { authUpload, keys };
}
/**
* Adds the key backup info to be updated on the server
*
* Used either to create a new key backup, or add signatures
* from the new MSK.
*
* @param keyBackupInfo - as received from/sent to the server
*/
public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void {
this.keyBackupInfo = keyBackupInfo;
}
/**
* Adds the session backup private key to be updated in the local cache
*
* Used after fixing the format of the key
*
*/
public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void {
this.sessionBackupPrivateKey = privateKey;
}
/**
* Add signatures from a given user and device/x-sign key
* Used to sign the new cross-signing key with the device key
*
*/
public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void {
if (!this.keySignatures) {
this.keySignatures = {};
}
const userSignatures = this.keySignatures[userId] ?? {};
this.keySignatures[userId] = userSignatures;
userSignatures[deviceId] = signature;
}
public async setAccountData<K extends keyof AccountDataEvents>(
type: K,
content: AccountDataEvents[K],
): Promise<void> {
await this.accountDataClientAdapter.setAccountData(type, content);
}
/**
* builds the operation containing all the parts that have been added to the builder
*/
public buildOperation(): EncryptionSetupOperation {
const accountData = this.accountDataClientAdapter.values;
return new EncryptionSetupOperation(accountData, this.crossSigningKeys, this.keyBackupInfo, this.keySignatures);
}
/**
* Stores the created keys locally.
*
* This does not yet store the operation in a way that it can be restored,
* but that is the idea in the future.
*/
public async persist(crypto: Crypto): Promise<void> {
// store private keys in cache
if (this.crossSigningKeys) {
const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice);
for (const type of ["master", "self_signing", "user_signing"]) {
logger.log(`Cache ${type} cross-signing private key locally`);
const privateKey = this.crossSigningCallbacks.privateKeys.get(type);
await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey);
}
// store own cross-sign pubkeys as trusted
await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys!.keys);
});
}
// store session backup key in cache
if (this.sessionBackupPrivateKey) {
await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey);
}
}
}
/**
* Can be created from EncryptionSetupBuilder, or
* (in a follow-up PR, not implemented yet) restored from storage, to retry.
*
* It does not have knowledge of any private keys, unlike the builder.
*/
export class EncryptionSetupOperation {
/**
*/
public constructor(
private readonly accountData: Map<keyof AccountDataEvents, MatrixEvent>,
private readonly crossSigningKeys?: ICrossSigningKeys,
private readonly keyBackupInfo?: IKeyBackupInfo,
private readonly keySignatures?: KeySignatures,
) {}
/**
* Runs the (remaining part of, in the future) operation by sending requests to the server.
*/
public async apply(crypto: Crypto): Promise<void> {
const baseApis = crypto.baseApis;
// upload cross-signing keys
if (this.crossSigningKeys) {
const keys: Partial<CrossSigningKeys> = {};
for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) {
keys[((name as keyof ICrossSigningKeys["keys"]) + "_key") as keyof CrossSigningKeys] = key;
}
// We must only call `uploadDeviceSigningKeys` from inside this auth
// helper to ensure we properly handle auth errors.
await this.crossSigningKeys.authUpload?.((authDict) => {
return baseApis.uploadDeviceSigningKeys(authDict ?? undefined, keys as CrossSigningKeys);
});
// pass the new keys to the main instance of our own CrossSigningInfo.
crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys);
}
// set account data
if (this.accountData) {
for (const [type, content] of this.accountData) {
await baseApis.setAccountData(type, content.getContent());
}
}
// upload first cross-signing signatures with the new key
// (e.g. signing our own device)
if (this.keySignatures) {
await baseApis.uploadKeySignatures(this.keySignatures);
}
// need to create/update key backup info
if (this.keyBackupInfo) {
if (this.keyBackupInfo.version) {
// session backup signature
// The backup is trusted because the user provided the private key.
// Sign the backup with the cross signing key so the key backup can
// be trusted via cross-signing.
await baseApis.http.authedRequest(
Method.Put,
"/room_keys/version/" + this.keyBackupInfo.version,
undefined,
{
algorithm: this.keyBackupInfo.algorithm,
auth_data: this.keyBackupInfo.auth_data,
},
{ prefix: ClientPrefix.V3 },
);
} else {
// add new key backup
await baseApis.http.authedRequest(Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, {
prefix: ClientPrefix.V3,
});
}
// tell the backup manager to re-check the keys now that they have been (maybe) updated
await crypto.backupManager.checkKeyBackup();
}
}
}
/**
* Catches account data set by SecretStorage during bootstrapping by
* implementing the methods related to account data in MatrixClient
*/
class AccountDataClientAdapter
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
implements AccountDataClient
{
//
public readonly values = new Map<keyof AccountDataEvents, MatrixEvent>();
/**
* @param existingValues - existing account data
*/
public constructor(private readonly existingValues: Map<string, MatrixEvent>) {
super();
}
/**
* @returns the content of the account data
*/
public getAccountDataFromServer<K extends keyof AccountDataEvents>(type: K): Promise<AccountDataEvents[K] | null> {
return Promise.resolve(this.getAccountData(type));
}
/**
* @returns the content of the account data
*/
public getAccountData<K extends keyof AccountDataEvents>(type: K): AccountDataEvents[K] | null {
const event = this.values.get(type) ?? this.existingValues.get(type);
return event?.getContent<AccountDataEvents[K]>() ?? null;
}
public setAccountData<K extends keyof AccountDataEvents>(
type: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
const event = new MatrixEvent({ type, content });
const lastEvent = this.values.get(type);
this.values.set(type, event);
// ensure accountData is emitted on the next tick,
// as SecretStorage listens for it while calling this method
// and it seems to rely on this.
return Promise.resolve().then(() => {
this.emit(ClientEvent.AccountData, event, lastEvent);
return {};
});
}
}
/**
* Catches the private cross-signing keys set during bootstrapping
* by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks.
* See CrossSigningInfo constructor
*/
class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks {
public readonly privateKeys = new Map<string, Uint8Array>();
// cache callbacks
public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise<Uint8Array | null> {
return this.getCrossSigningKey(type, expectedPublicKey);
}
public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise<void> {
this.privateKeys.set(type, key);
return Promise.resolve();
}
// non-cache callbacks
public getCrossSigningKey(type: string, expectedPubkey: string): Promise<Uint8Array | null> {
return Promise.resolve(this.privateKeys.get(type) ?? null);
}
public saveCrossSigningKeys(privateKeys: Record<string, Uint8Array>): void {
for (const [type, privateKey] of Object.entries(privateKeys)) {
this.privateKeys.set(type, privateKey);
}
}
}
/**
* Catches the 4S private key set during bootstrapping by implementing
* the SecretStorage crypto callbacks
*/
class SSSSCryptoCallbacks {
private readonly privateKeys = new Map<string, Uint8Array>();
public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {}
public async getSecretStorageKey(
{ keys }: { keys: Record<string, SecretStorageKeyDescription> },
name: string,
): Promise<[string, Uint8Array] | null> {
for (const keyId of Object.keys(keys)) {
const privateKey = this.privateKeys.get(keyId);
if (privateKey) {
return [keyId, privateKey];
}
}
// if we don't have the key cached yet, ask
// for it to the general crypto callbacks and cache it
if (this?.delegateCryptoCallbacks?.getSecretStorageKey) {
const result = await this.delegateCryptoCallbacks.getSecretStorageKey({ keys }, name);
if (result) {
const [keyId, privateKey] = result;
this.privateKeys.set(keyId, privateKey);
}
return result;
}
return null;
}
public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void {
this.privateKeys.set(keyId, privKey);
// Also pass along to application to cache if it wishes
this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,486 +0,0 @@
/*
Copyright 2017 - 2021 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 { v4 as uuidv4 } from "uuid";
import { logger } from "../logger.ts";
import { type MatrixClient } from "../client.ts";
import { type IRoomKeyRequestBody, type IRoomKeyRequestRecipient } from "./index.ts";
import { type CryptoStore, type OutgoingRoomKeyRequest } from "./store/base.ts";
import { EventType, ToDeviceMessageId } from "../@types/event.ts";
import { MapWithDefault } from "../utils.ts";
import { type EmptyObject } from "../@types/common.ts";
/**
* Internal module. Management of outgoing room key requests.
*
* See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ
* for draft documentation on what we're supposed to be implementing here.
*/
// delay between deciding we want some keys, and sending out the request, to
// allow for (a) it turning up anyway, (b) grouping requests together
const SEND_KEY_REQUESTS_DELAY_MS = 500;
/**
* possible states for a room key request
*
* The state machine looks like:
* ```
*
* | (cancellation sent)
* | .-------------------------------------------------.
* | | |
* V V (cancellation requested) |
* UNSENT -----------------------------+ |
* | | |
* | | |
* | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND
* V | Λ
* SENT | |
* |-------------------------------- | --------------'
* | | (cancellation requested with intent
* | | to resend the original request)
* | |
* | (cancellation requested) |
* V |
* CANCELLATION_PENDING |
* | |
* | (cancellation sent) |
* V |
* (deleted) <---------------------------+
* ```
*/
export enum RoomKeyRequestState {
/** request not yet sent */
Unsent,
/** request sent, awaiting reply */
Sent,
/** reply received, cancellation not yet sent */
CancellationPending,
/**
* Cancellation not yet sent and will transition to UNSENT instead of
* being deleted once the cancellation has been sent.
*/
CancellationPendingAndWillResend,
}
interface RequestMessageBase {
requesting_device_id: string;
request_id: string;
}
interface RequestMessageRequest extends RequestMessageBase {
action: "request";
body: IRoomKeyRequestBody;
}
interface RequestMessageCancellation extends RequestMessageBase {
action: "request_cancellation";
}
type RequestMessage = RequestMessageRequest | RequestMessageCancellation;
export class OutgoingRoomKeyRequestManager {
// handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null
// if the callback has been set, or if it is still running.
private sendOutgoingRoomKeyRequestsTimer?: ReturnType<typeof setTimeout>;
// sanity check to ensure that we don't end up with two concurrent runs
// of sendOutgoingRoomKeyRequests
private sendOutgoingRoomKeyRequestsRunning = false;
private clientRunning = true;
public constructor(
private readonly baseApis: MatrixClient,
private readonly deviceId: string,
private readonly cryptoStore: CryptoStore,
) {}
/**
* Called when the client is stopped. Stops any running background processes.
*/
public stop(): void {
logger.log("stopping OutgoingRoomKeyRequestManager");
// stop the timer on the next run
this.clientRunning = false;
}
/**
* Send any requests that have been queued
*/
public sendQueuedRequests(): void {
this.startTimer();
}
/**
* Queue up a room key request, if we haven't already queued or sent one.
*
* The `requestBody` is compared (with a deep-equality check) against
* previous queued or sent requests and if it matches, no change is made.
* Otherwise, a request is added to the pending list, and a job is started
* in the background to send it.
*
* @param resend - whether to resend the key request if there is
* already one
*
* @returns resolves when the request has been added to the
* pending list (or we have established that a similar request already
* exists)
*/
public async queueRoomKeyRequest(
requestBody: IRoomKeyRequestBody,
recipients: IRoomKeyRequestRecipient[],
resend = false,
): Promise<void> {
const req = await this.cryptoStore.getOutgoingRoomKeyRequest(requestBody);
if (!req) {
await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({
requestBody: requestBody,
recipients: recipients,
requestId: this.baseApis.makeTxnId(),
state: RoomKeyRequestState.Unsent,
});
} else {
switch (req.state) {
case RoomKeyRequestState.CancellationPendingAndWillResend:
case RoomKeyRequestState.Unsent:
// nothing to do here, since we're going to send a request anyways
return;
case RoomKeyRequestState.CancellationPending: {
// existing request is about to be cancelled. If we want to
// resend, then change the state so that it resends after
// cancelling. Otherwise, just cancel the cancellation.
const state = resend
? RoomKeyRequestState.CancellationPendingAndWillResend
: RoomKeyRequestState.Sent;
await this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId,
RoomKeyRequestState.CancellationPending,
{
state,
cancellationTxnId: this.baseApis.makeTxnId(),
},
);
break;
}
case RoomKeyRequestState.Sent: {
// a request has already been sent. If we don't want to
// resend, then do nothing. If we do want to, then cancel the
// existing request and send a new one.
if (resend) {
const state = RoomKeyRequestState.CancellationPendingAndWillResend;
const updatedReq = await this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId,
RoomKeyRequestState.Sent,
{
state,
cancellationTxnId: this.baseApis.makeTxnId(),
// need to use a new transaction ID so that
// the request gets sent
requestTxnId: this.baseApis.makeTxnId(),
},
);
if (!updatedReq) {
// updateOutgoingRoomKeyRequest couldn't find the request
// in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
// raced with another tab to mark the request cancelled.
// Try again, to make sure the request is resent.
return this.queueRoomKeyRequest(requestBody, recipients, resend);
}
// We don't want to wait for the timer, so we send it
// immediately. (We might actually end up racing with the timer,
// but that's ok: even if we make the request twice, we'll do it
// with the same transaction_id, so only one message will get
// sent).
//
// (We also don't want to wait for the response from the server
// here, as it will slow down processing of received keys if we
// do.)
try {
await this.sendOutgoingRoomKeyRequestCancellation(updatedReq, true);
} catch (e) {
logger.error("Error sending room key request cancellation;" + " will retry later.", e);
}
// The request has transitioned from
// CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We
// still need to resend the request which is now UNSENT, so
// start the timer if it isn't already started.
}
break;
}
default:
throw new Error("unhandled state: " + req.state);
}
}
}
/**
* Cancel room key requests, if any match the given requestBody
*
*
* @returns resolves when the request has been updated in our
* pending list.
*/
public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<unknown> {
return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then((req): unknown => {
if (!req) {
// no request was made for this key
return;
}
switch (req.state) {
case RoomKeyRequestState.CancellationPending:
case RoomKeyRequestState.CancellationPendingAndWillResend:
// nothing to do here
return;
case RoomKeyRequestState.Unsent:
// just delete it
// FIXME: ghahah we may have attempted to send it, and
// not yet got a successful response. So the server
// may have seen it, so we still need to send a cancellation
// in that case :/
logger.log("deleting unnecessary room key request for " + stringifyRequestBody(requestBody));
return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent);
case RoomKeyRequestState.Sent: {
// send a cancellation.
return this.cryptoStore
.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, {
state: RoomKeyRequestState.CancellationPending,
cancellationTxnId: this.baseApis.makeTxnId(),
})
.then((updatedReq) => {
if (!updatedReq) {
// updateOutgoingRoomKeyRequest couldn't find the
// request in state ROOM_KEY_REQUEST_STATES.SENT,
// so we must have raced with another tab to mark
// the request cancelled. There is no point in
// sending another cancellation since the other tab
// will do it.
logger.log(
"Tried to cancel room key request for " +
stringifyRequestBody(requestBody) +
" but it was already cancelled in another tab",
);
return;
}
// We don't want to wait for the timer, so we send it
// immediately. (We might actually end up racing with the timer,
// but that's ok: even if we make the request twice, we'll do it
// with the same transaction_id, so only one message will get
// sent).
//
// (We also don't want to wait for the response from the server
// here, as it will slow down processing of received keys if we
// do.)
this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch((e) => {
logger.error("Error sending room key request cancellation;" + " will retry later.", e);
this.startTimer();
});
});
}
default:
throw new Error("unhandled state: " + req.state);
}
});
}
/**
* Look for room key requests by target device and state
*
* @param userId - Target user ID
* @param deviceId - Target device ID
*
* @returns resolves to a list of all the {@link OutgoingRoomKeyRequest}
*/
public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): Promise<OutgoingRoomKeyRequest[]> {
return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]);
}
/**
* Find anything in `sent` state, and kick it around the loop again.
* This is intended for situations where something substantial has changed, and we
* don't really expect the other end to even care about the cancellation.
* For example, after initialization or self-verification.
* @returns An array of `queueRoomKeyRequest` outputs.
*/
public async cancelAndResendAllOutgoingRequests(): Promise<void[]> {
const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
return Promise.all(
outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true)),
);
}
// start the background timer to send queued requests, if the timer isn't
// already running
private startTimer(): void {
if (this.sendOutgoingRoomKeyRequestsTimer) {
return;
}
const startSendingOutgoingRoomKeyRequests = (): void => {
if (this.sendOutgoingRoomKeyRequestsRunning) {
throw new Error("RoomKeyRequestSend already in progress!");
}
this.sendOutgoingRoomKeyRequestsRunning = true;
this.sendOutgoingRoomKeyRequests()
.finally(() => {
this.sendOutgoingRoomKeyRequestsRunning = false;
})
.catch((e) => {
// this should only happen if there is an indexeddb error,
// in which case we're a bit stuffed anyway.
logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`);
});
};
this.sendOutgoingRoomKeyRequestsTimer = setTimeout(
startSendingOutgoingRoomKeyRequests,
SEND_KEY_REQUESTS_DELAY_MS,
);
}
// look for and send any queued requests. Runs itself recursively until
// there are no more requests, or there is an error (in which case, the
// timer will be restarted before the promise resolves).
private async sendOutgoingRoomKeyRequests(): Promise<void> {
if (!this.clientRunning) {
this.sendOutgoingRoomKeyRequestsTimer = undefined;
return;
}
const req = await this.cryptoStore.getOutgoingRoomKeyRequestByState([
RoomKeyRequestState.CancellationPending,
RoomKeyRequestState.CancellationPendingAndWillResend,
RoomKeyRequestState.Unsent,
]);
if (!req) {
this.sendOutgoingRoomKeyRequestsTimer = undefined;
return;
}
try {
switch (req.state) {
case RoomKeyRequestState.Unsent:
await this.sendOutgoingRoomKeyRequest(req);
break;
case RoomKeyRequestState.CancellationPending:
await this.sendOutgoingRoomKeyRequestCancellation(req);
break;
case RoomKeyRequestState.CancellationPendingAndWillResend:
await this.sendOutgoingRoomKeyRequestCancellation(req, true);
break;
}
// go around the loop again
return this.sendOutgoingRoomKeyRequests();
} catch (e) {
logger.error("Error sending room key request; will retry later.", e);
this.sendOutgoingRoomKeyRequestsTimer = undefined;
}
}
// given a RoomKeyRequest, send it and update the request record
private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise<unknown> {
logger.log(
`Requesting keys for ${stringifyRequestBody(req.requestBody)}` +
` from ${stringifyRecipientList(req.recipients)}` +
`(id ${req.requestId})`,
);
const requestMessage: RequestMessage = {
action: "request",
requesting_device_id: this.deviceId,
request_id: req.requestId,
body: req.requestBody,
};
return this.sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => {
return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent, {
state: RoomKeyRequestState.Sent,
});
});
}
// Given a RoomKeyRequest, cancel it and delete the request record unless
// andResend is set, in which case transition to UNSENT.
private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise<unknown> {
logger.log(
`Sending cancellation for key request for ` +
`${stringifyRequestBody(req.requestBody)} to ` +
`${stringifyRecipientList(req.recipients)} ` +
`(cancellation id ${req.cancellationTxnId})`,
);
const requestMessage: RequestMessage = {
action: "request_cancellation",
requesting_device_id: this.deviceId,
request_id: req.requestId,
};
return this.sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => {
if (andResend) {
// We want to resend, so transition to UNSENT
return this.cryptoStore.updateOutgoingRoomKeyRequest(
req.requestId,
RoomKeyRequestState.CancellationPendingAndWillResend,
{ state: RoomKeyRequestState.Unsent },
);
}
return this.cryptoStore.deleteOutgoingRoomKeyRequest(
req.requestId,
RoomKeyRequestState.CancellationPending,
);
});
}
// send a RoomKeyRequest to a list of recipients
private sendMessageToDevices(
message: RequestMessage,
recipients: IRoomKeyRequestRecipient[],
txnId?: string,
): Promise<EmptyObject> {
const contentMap = new MapWithDefault<string, Map<string, Record<string, any>>>(() => new Map());
for (const recip of recipients) {
const userDeviceMap = contentMap.getOrCreate(recip.userId);
userDeviceMap.set(recip.deviceId, {
...message,
[ToDeviceMessageId]: uuidv4(),
});
}
return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId);
}
}
function stringifyRequestBody(requestBody: IRoomKeyRequestBody): string {
// we assume that the request is for megolm keys, which are identified by
// room id and session id
return requestBody.room_id + " / " + requestBody.session_id;
}
function stringifyRecipientList(recipients: IRoomKeyRequestRecipient[]): string {
return `[${recipients.map((r) => `${r.userId}:${r.deviceId}`).join(",")}]`;
}

View File

@ -1,70 +0,0 @@
/*
Copyright 2018 - 2021 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.
*/
/**
* Manages the list of encrypted rooms
*/
import { type CryptoStore } from "./store/base.ts";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts";
/* eslint-disable camelcase */
export interface IRoomEncryption {
algorithm: string;
rotation_period_ms?: number;
rotation_period_msgs?: number;
}
/* eslint-enable camelcase */
/**
* Information about the encryption settings of rooms. Loads this information
* from the supplied crypto store when `init()` is called, and saves it to the
* crypto store whenever it is updated via `setRoomEncryption()`. Can supply
* full information about a room's encryption via `getRoomEncryption()`, or just
* answer whether or not a room has encryption via `isRoomEncrypted`.
*/
export class RoomList {
// Object of roomId -> room e2e info object (body of the m.room.encryption event)
private roomEncryption: Record<string, IRoomEncryption> = {};
public constructor(private readonly cryptoStore?: CryptoStore) {}
public async init(): Promise<void> {
await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
this.cryptoStore!.getEndToEndRooms(txn, (result) => {
this.roomEncryption = result;
});
});
}
public getRoomEncryption(roomId: string): IRoomEncryption | null {
return this.roomEncryption[roomId] || null;
}
public isRoomEncrypted(roomId: string): boolean {
return Boolean(this.getRoomEncryption(roomId));
}
public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise<void> {
// important that this happens before calling into the store
// as it prevents the Crypto::setRoomEncryption from calling
// this twice for consecutive m.room.encryption events
this.roomEncryption[roomId] = roomInfo;
await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn);
});
}
}

View File

@ -1,240 +0,0 @@
/*
Copyright 2019-2023 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 { v4 as uuidv4 } from "uuid";
import { type MatrixClient } from "../client.ts";
import { type ICryptoCallbacks, type IEncryptedContent } from "./index.ts";
import { defer, type IDeferred } from "../utils.ts";
import { ToDeviceMessageId } from "../@types/event.ts";
import { logger } from "../logger.ts";
import { type MatrixEvent } from "../models/event.ts";
import * as olmlib from "./olmlib.ts";
export interface ISecretRequest {
requestId: string;
promise: Promise<string>;
cancel: (reason: string) => void;
}
interface ISecretRequestInternal {
name: string;
devices: string[];
deferred: IDeferred<string>;
}
export class SecretSharing {
private requests = new Map<string, ISecretRequestInternal>();
public constructor(
private readonly baseApis: MatrixClient,
private readonly cryptoCallbacks: ICryptoCallbacks,
) {}
/**
* Request a secret from another device
*
* @param name - the name of the secret to request
* @param devices - the devices to request the secret from
*/
public request(name: string, devices: string[]): ISecretRequest {
const requestId = this.baseApis.makeTxnId();
const deferred = defer<string>();
this.requests.set(requestId, { name, devices, deferred });
const cancel = (reason: string): void => {
// send cancellation event
const cancelData = {
action: "request_cancellation",
requesting_device_id: this.baseApis.deviceId,
request_id: requestId,
};
const toDevice: Map<string, typeof cancelData> = new Map();
for (const device of devices) {
toDevice.set(device, cancelData);
}
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
// and reject the promise so that anyone waiting on it will be
// notified
deferred.reject(new Error(reason || "Cancelled"));
};
// send request to devices
const requestData = {
name,
action: "request",
requesting_device_id: this.baseApis.deviceId,
request_id: requestId,
[ToDeviceMessageId]: uuidv4(),
};
const toDevice: Map<string, typeof requestData> = new Map();
for (const device of devices) {
toDevice.set(device, requestData);
}
logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
return {
requestId,
promise: deferred.promise,
cancel,
};
}
public async onRequestReceived(event: MatrixEvent): Promise<void> {
const sender = event.getSender();
const content = event.getContent();
if (
sender !== this.baseApis.getUserId() ||
!(content.name && content.action && content.requesting_device_id && content.request_id)
) {
// ignore requests from anyone else, for now
return;
}
const deviceId = content.requesting_device_id;
// check if it's a cancel
if (content.action === "request_cancellation") {
/*
Looks like we intended to emit events when we got cancelations, but
we never put anything in the _incomingRequests object, and the request
itself doesn't use events anyway so if we were to wire up cancellations,
they probably ought to use the same callback interface. I'm leaving them
disabled for now while converting this file to typescript.
if (this._incomingRequests[deviceId]
&& this._incomingRequests[deviceId][content.request_id]) {
logger.info(
"received request cancellation for secret (" + sender +
", " + deviceId + ", " + content.request_id + ")",
);
this.baseApis.emit("crypto.secrets.requestCancelled", {
user_id: sender,
device_id: deviceId,
request_id: content.request_id,
});
}
*/
} else if (content.action === "request") {
if (deviceId === this.baseApis.deviceId) {
// no point in trying to send ourself the secret
return;
}
// check if we have the secret
logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")");
if (!this.cryptoCallbacks.onSecretRequested) {
return;
}
const secret = await this.cryptoCallbacks.onSecretRequested(
sender,
deviceId,
content.request_id,
content.name,
this.baseApis.checkDeviceTrust(sender, deviceId),
);
if (secret) {
logger.info(`Preparing ${content.name} secret for ${deviceId}`);
const payload = {
type: "m.secret.send",
content: {
request_id: content.request_id,
secret: secret,
},
};
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!,
ciphertext: {},
[ToDeviceMessageId]: uuidv4(),
};
await olmlib.ensureOlmSessionsForDevices(
this.baseApis.crypto!.olmDevice,
this.baseApis,
new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]),
);
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.baseApis.getUserId()!,
this.baseApis.deviceId!,
this.baseApis.crypto!.olmDevice,
sender,
this.baseApis.getStoredDevice(sender, deviceId)!,
payload,
);
const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]);
logger.info(`Sending ${content.name} secret for ${deviceId}`);
this.baseApis.sendToDevice("m.room.encrypted", contentMap);
} else {
logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
}
}
}
public onSecretReceived(event: MatrixEvent): void {
if (event.getSender() !== this.baseApis.getUserId()) {
// we shouldn't be receiving secrets from anyone else, so ignore
// because someone could be trying to send us bogus data
return;
}
if (!olmlib.isOlmEncrypted(event)) {
logger.error("secret event not properly encrypted");
return;
}
const content = event.getContent();
const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey() || "",
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
logger.log("got secret share for request", content.request_id);
const requestControl = this.requests.get(content.request_id);
if (requestControl) {
// make sure that the device that sent it is one of the devices that
// we requested from
const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey()!,
);
if (!deviceInfo) {
logger.log("secret share from unknown device with key", event.getSenderKey());
return;
}
if (!requestControl.devices.includes(deviceInfo.deviceId)) {
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
return;
}
// unsure that the sender is trusted. In theory, this check is
// unnecessary since we only accept secret shares from devices that
// we requested from, but it doesn't hurt.
const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo);
if (!deviceTrust.isVerified()) {
logger.log("secret share from unverified device");
return;
}
logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
requestControl.deferred.resolve(content.secret);
}
}
}

View File

@ -1,136 +0,0 @@
/*
Copyright 2019 - 2021 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 { type ICryptoCallbacks } from "./index.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type MatrixClient } from "../client.ts";
import {
type SecretStorageKeyDescription,
type SecretStorageKeyTuple,
type SecretStorageKeyObject,
type AddSecretStorageKeyOpts,
type AccountDataClient,
type ServerSideSecretStorage,
ServerSideSecretStorageImpl,
type SecretStorageKey,
} from "../secret-storage.ts";
import { type ISecretRequest, SecretSharing } from "./SecretSharing.ts";
/* re-exports for backwards compatibility */
export type {
SecretStorageKeyTuple,
SecretStorageKeyObject,
SECRET_STORAGE_ALGORITHM_V1_AES,
} from "../secret-storage.ts";
export type { ISecretRequest } from "./SecretSharing.ts";
/**
* Implements Secure Secret Storage and Sharing (MSC1946)
*
* @deprecated This is just a backwards-compatibility hack which will be removed soon.
* Use {@link SecretStorage.ServerSideSecretStorageImpl} from `../secret-storage` and/or {@link SecretSharing} from `./SecretSharing`.
*/
export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> implements ServerSideSecretStorage {
private readonly storageImpl: ServerSideSecretStorageImpl;
private readonly sharingImpl: SecretSharing;
// In its pure javascript days, this was relying on some proper Javascript-style
// type-abuse where sometimes we'd pass in a fake client object with just the account
// data methods implemented, which is all this class needs unless you use the secret
// sharing code, so it was fine. As a low-touch TypeScript migration, we added
// an extra, optional param for a real matrix client, so you can not pass it as long
// as you don't request any secrets.
//
// Nowadays, the whole class is scheduled for destruction, once we get rid of the legacy
// Crypto impl that exposes it.
public constructor(accountDataAdapter: AccountDataClient, cryptoCallbacks: ICryptoCallbacks, baseApis: B) {
this.storageImpl = new ServerSideSecretStorageImpl(accountDataAdapter, cryptoCallbacks);
this.sharingImpl = new SecretSharing(baseApis as MatrixClient, cryptoCallbacks);
}
public getDefaultKeyId(): Promise<string | null> {
return this.storageImpl.getDefaultKeyId();
}
public setDefaultKeyId(keyId: string): Promise<void> {
return this.storageImpl.setDefaultKeyId(keyId);
}
/**
* Add a key for encrypting secrets.
*/
public addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise<SecretStorageKeyObject> {
return this.storageImpl.addKey(algorithm, opts, keyId);
}
/**
* Get the key information for a given ID.
*/
public getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
return this.storageImpl.getKey(keyId);
}
/**
* Check whether we have a key with a given ID.
*/
public hasKey(keyId?: string): Promise<boolean> {
return this.storageImpl.hasKey(keyId);
}
/**
* Check whether a key matches what we expect based on the key info
*/
public checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
return this.storageImpl.checkKey(key, info);
}
/**
* Store an encrypted secret on the server
*/
public store(name: SecretStorageKey, secret: string, keys?: string[] | null): Promise<void> {
return this.storageImpl.store(name, secret, keys);
}
/**
* Get a secret from storage.
*/
public get(name: SecretStorageKey): Promise<string | undefined> {
return this.storageImpl.get(name);
}
/**
* Check if a secret is stored on the server.
*/
public async isStored(name: SecretStorageKey): Promise<Record<string, SecretStorageKeyDescription> | null> {
return this.storageImpl.isStored(name);
}
/**
* Request a secret from another device
*/
public request(name: string, devices: string[]): ISecretRequest {
return this.sharingImpl.request(name, devices);
}
public onRequestReceived(event: MatrixEvent): Promise<void> {
return this.sharingImpl.onRequestReceived(event);
}
public onSecretReceived(event: MatrixEvent): void {
this.sharingImpl.onSecretReceived(event);
}
}

View File

@ -1,23 +0,0 @@
/*
Copyright 2020 - 2021 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 encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts";
import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts";
// Export for backwards compatibility
export type { AESEncryptedSecretStoragePayload as IEncryptedPayload } from "../@types/AESEncryptedSecretStoragePayload.ts";
export { encryptAESSecretStorageItem as encryptAES, decryptAESSecretStorageItem as decryptAES };
export { calculateKeyCheck } from "../secret-storage.ts";

View File

@ -1,241 +0,0 @@
/*
Copyright 2016 - 2021 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.
*/
/**
* Internal module. Defines the base classes of the encryption implementations
*/
import type { IMegolmSessionData } from "../../@types/crypto.ts";
import { type MatrixClient } from "../../client.ts";
import { type Room } from "../../models/room.ts";
import { type OlmDevice } from "../OlmDevice.ts";
import { type IContent, type MatrixEvent, type RoomMember } from "../../matrix.ts";
import {
type Crypto,
type IEncryptedContent,
type IEventDecryptionResult,
type IncomingRoomKeyRequest,
} from "../index.ts";
import { type DeviceInfo } from "../deviceinfo.ts";
import { type IRoomEncryption } from "../RoomList.ts";
import { type DeviceInfoMap } from "../DeviceList.ts";
/**
* Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class
*/
export const ENCRYPTION_CLASSES = new Map<string, new (params: IParams) => EncryptionAlgorithm>();
export type DecryptionClassParams<P extends IParams = IParams> = Omit<P, "deviceId" | "config">;
/**
* map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class
*/
export const DECRYPTION_CLASSES = new Map<string, new (params: DecryptionClassParams) => DecryptionAlgorithm>();
export interface IParams {
/** The UserID for the local user */
userId: string;
/** The identifier for this device. */
deviceId: string;
/** crypto core */
crypto: Crypto;
/** olm.js wrapper */
olmDevice: OlmDevice;
/** base matrix api interface */
baseApis: MatrixClient;
/** The ID of the room we will be sending to */
roomId?: string;
/** The body of the m.room.encryption event */
config: IRoomEncryption & object;
}
/**
* base type for encryption implementations
*/
export abstract class EncryptionAlgorithm {
protected readonly userId: string;
protected readonly deviceId: string;
protected readonly crypto: Crypto;
protected readonly olmDevice: OlmDevice;
protected readonly baseApis: MatrixClient;
/**
* @param params - parameters
*/
public constructor(params: IParams) {
this.userId = params.userId;
this.deviceId = params.deviceId;
this.crypto = params.crypto;
this.olmDevice = params.olmDevice;
this.baseApis = params.baseApis;
}
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
*
* @param room - the room the event is in
*/
public prepareToEncrypt(room: Room): void {}
/**
* Encrypt a message event
*
* @public
*
* @param content - event content
*
* @returns Promise which resolves to the new event body
*/
public abstract encryptMessage(room: Room, eventType: string, content: IContent): Promise<IEncryptedContent>;
/**
* Called when the membership of a member of the room changes.
*
* @param event - event causing the change
* @param member - user whose membership changed
* @param oldMembership - previous membership
* @public
*/
public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {}
public reshareKeyWithDevice?(
senderKey: string,
sessionId: string,
userId: string,
device: DeviceInfo,
): Promise<void>;
public forceDiscardSession?(): void;
}
/**
* base type for decryption implementations
*/
export abstract class DecryptionAlgorithm {
protected readonly userId: string;
protected readonly crypto: Crypto;
protected readonly olmDevice: OlmDevice;
protected readonly baseApis: MatrixClient;
public constructor(params: DecryptionClassParams) {
this.userId = params.userId;
this.crypto = params.crypto;
this.olmDevice = params.olmDevice;
this.baseApis = params.baseApis;
}
/**
* Decrypt an event
*
* @param event - undecrypted event
*
* @returns promise which
* resolves once we have finished decrypting. Rejects with an
* `algorithms.DecryptionError` if there is a problem decrypting the event.
*/
public abstract decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
/**
* Handle a key event
*
* @param params - event key event
*/
public async onRoomKeyEvent(params: MatrixEvent): Promise<void> {
// ignore by default
}
/**
* Import a room key
*
* @param opts - object
*/
public async importRoomKey(session: IMegolmSessionData, opts: object): Promise<void> {
// ignore by default
}
/**
* Determine if we have the keys necessary to respond to a room key request
*
* @returns true if we have the keys and could (theoretically) share
* them; else false.
*/
public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> {
return Promise.resolve(false);
}
/**
* Send the response to a room key request
*
*/
public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void {
throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm");
}
/**
* Retry decrypting all the events from a sender that haven't been
* decrypted yet.
*
* @param senderKey - the sender's key
*/
public async retryDecryptionFromSender(senderKey: string): Promise<boolean> {
// ignore by default
return false;
}
public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise<void>;
public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>;
}
export class UnknownDeviceError extends Error {
/**
* Exception thrown specifically when we want to warn the user to consider
* the security of their conversation before continuing
*
* @param msg - message describing the problem
* @param devices - set of unknown devices per user we're warning about
*/
public constructor(
msg: string,
public readonly devices: DeviceInfoMap,
public event?: MatrixEvent,
) {
super(msg);
this.name = "UnknownDeviceError";
this.devices = devices;
}
}
/**
* Registers an encryption/decryption class for a particular algorithm
*
* @param algorithm - algorithm tag to register for
*
* @param encryptor - {@link EncryptionAlgorithm} implementation
*
* @param decryptor - {@link DecryptionAlgorithm} implementation
*/
export function registerAlgorithm<P extends IParams = IParams>(
algorithm: string,
encryptor: new (params: P) => EncryptionAlgorithm,
decryptor: new (params: DecryptionClassParams<P>) => DecryptionAlgorithm,
): void {
ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm);
DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm);
}
/* Re-export for backwards compatibility. Deprecated: this is an internal class. */
export { DecryptionError } from "../../common-crypto/CryptoBackend.ts";

View File

@ -1,20 +0,0 @@
/*
Copyright 2016 - 2021 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 "./olm.ts";
import "./megolm.ts";
export * from "./base.ts";

File diff suppressed because it is too large Load Diff

View File

@ -1,381 +0,0 @@
/*
Copyright 2016 - 2021 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.
*/
/**
* Defines m.olm encryption/decryption
*/
import type { IEventDecryptionResult } from "../../@types/crypto.ts";
import { logger } from "../../logger.ts";
import * as olmlib from "../olmlib.ts";
import { DeviceInfo } from "../deviceinfo.ts";
import { DecryptionAlgorithm, EncryptionAlgorithm, registerAlgorithm } from "./base.ts";
import { type Room } from "../../models/room.ts";
import { type IContent, type MatrixEvent } from "../../models/event.ts";
import { type IEncryptedContent, type IOlmEncryptedContent } from "../index.ts";
import { type IInboundSession } from "../OlmDevice.ts";
import { DecryptionFailureCode } from "../../crypto-api/index.ts";
import { DecryptionError } from "../../common-crypto/CryptoBackend.ts";
const DeviceVerification = DeviceInfo.DeviceVerification;
export interface IMessage {
type: number;
body: string;
}
/**
* Olm encryption implementation
*
* @param params - parameters, as per {@link EncryptionAlgorithm}
*/
class OlmEncryption extends EncryptionAlgorithm {
private sessionPrepared = false;
private prepPromise: Promise<void> | null = null;
/**
* @internal
* @param roomMembers - list of currently-joined users in the room
* @returns Promise which resolves when setup is complete
*/
private ensureSession(roomMembers: string[]): Promise<void> {
if (this.prepPromise) {
// prep already in progress
return this.prepPromise;
}
if (this.sessionPrepared) {
// prep already done
return Promise.resolve();
}
this.prepPromise = this.crypto
.downloadKeys(roomMembers)
.then(() => {
return this.crypto.ensureOlmSessionsForUsers(roomMembers);
})
.then(() => {
this.sessionPrepared = true;
})
.finally(() => {
this.prepPromise = null;
});
return this.prepPromise;
}
/**
* @param content - plaintext event content
*
* @returns Promise which resolves to the new event body
*/
public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IOlmEncryptedContent> {
// pick the list of recipients based on the membership list.
//
// TODO: there is a race condition here! What if a new user turns up
// just as you are sending a secret message?
const members = await room.getEncryptionTargetMembers();
const users = members.map(function (u) {
return u.userId;
});
await this.ensureSession(users);
const payloadFields = {
room_id: room.roomId,
type: eventType,
content: content,
};
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key!,
ciphertext: {},
};
const promises: Promise<void>[] = [];
for (const userId of users) {
const devices = this.crypto.getStoredDevicesForUser(userId) || [];
for (const deviceInfo of devices) {
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
// don't bother setting up sessions with blocked users
continue;
}
promises.push(
olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
this.deviceId,
this.olmDevice,
userId,
deviceInfo,
payloadFields,
),
);
}
}
return Promise.all(promises).then(() => encryptedContent);
}
}
/**
* Olm decryption implementation
*
* @param params - parameters, as per {@link DecryptionAlgorithm}
*/
class OlmDecryption extends DecryptionAlgorithm {
/**
* returns a promise which resolves to a
* {@link EventDecryptionResult} once we have finished
* decrypting. Rejects with an `algorithms.DecryptionError` if there is a
* problem decrypting the event.
*/
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
const content = event.getWireContent();
const deviceKey = content.sender_key;
const ciphertext = content.ciphertext;
if (!ciphertext) {
throw new DecryptionError(DecryptionFailureCode.OLM_MISSING_CIPHERTEXT, "Missing ciphertext");
}
if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) {
throw new DecryptionError(
DecryptionFailureCode.OLM_NOT_INCLUDED_IN_RECIPIENTS,
"Not included in recipients",
);
}
const message = ciphertext[this.olmDevice.deviceCurve25519Key!];
let payloadString: string;
try {
payloadString = await this.decryptMessage(deviceKey, message);
} catch (e) {
throw new DecryptionError(DecryptionFailureCode.OLM_BAD_ENCRYPTED_MESSAGE, "Bad Encrypted Message", {
sender: deviceKey,
err: e as Error,
});
}
const payload = JSON.parse(payloadString);
// check that we were the intended recipient, to avoid unknown-key attack
// https://github.com/vector-im/vector-web/issues/2483
if (payload.recipient != this.userId) {
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_RECIPIENT,
"Message was intended for " + payload.recipient,
);
}
if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_RECIPIENT_KEY,
"Message not intended for this device",
{
intended: payload.recipient_keys.ed25519,
our_key: this.olmDevice.deviceEd25519Key!,
},
);
}
// check that the device that encrypted the event belongs to the user that the event claims it's from.
//
// If the device is unknown then we check that we don't have any pending key-query requests for the sender. If
// after that the device is still unknown, then we can only assume that the device logged out and accept it
// anyway. Some event handlers, such as secret sharing, may be more strict and reject events that come from
// unknown devices.
//
// This is a defence against the following scenario:
//
// * Alice has verified Bob and Mallory.
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
// senderkey, but claiming to be from Bob.
// * Mallory sends more events using that session, claiming to be from Bob.
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those events as
// verified even though the sender is forged.
//
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
let senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
if (senderKeyUser === undefined || senderKeyUser === null) {
// Wait for any pending key query fetches for the user to complete before trying the lookup again.
try {
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
} catch (e) {
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_SENDER_CHECK_FAILED,
"Could not verify sender identity",
{
sender: deviceKey,
err: e as Error,
},
);
}
senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
}
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) {
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_SENDER,
"Message claimed to be from " + event.getSender(),
{
real_sender: senderKeyUser,
},
);
}
// check that the original sender matches what the homeserver told us, to
// avoid people masquerading as others.
// (this check is also provided via the sender's embedded ed25519 key,
// which is checked elsewhere).
if (payload.sender != event.getSender()) {
throw new DecryptionError(
DecryptionFailureCode.OLM_FORWARDED_MESSAGE,
"Message forwarded from " + payload.sender,
{
reported_sender: event.getSender()!,
},
);
}
// Olm events intended for a room have a room_id.
if (payload.room_id !== event.getRoomId()) {
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_ROOM,
"Message intended for room " + payload.room_id,
{
reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
},
);
}
const claimedKeys = payload.keys || {};
return {
clearEvent: payload,
senderCurve25519Key: deviceKey,
claimedEd25519Key: claimedKeys.ed25519 || null,
};
}
/**
* Attempt to decrypt an Olm message
*
* @param theirDeviceIdentityKey - Curve25519 identity key of the sender
* @param message - message object, with 'type' and 'body' fields
*
* @returns payload, if decrypted successfully.
*/
private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
// This is a wrapper that serialises decryptions of prekey messages, because
// otherwise we race between deciding we have no active sessions for the message
// and creating a new one, which we can only do once because it removes the OTK.
if (message.type !== 0) {
// not a prekey message: we can safely just try & decrypt it
return this.reallyDecryptMessage(theirDeviceIdentityKey, message);
} else {
const myPromise = this.olmDevice.olmPrekeyPromise.then(() => {
return this.reallyDecryptMessage(theirDeviceIdentityKey, message);
});
// we want the error, but don't propagate it to the next decryption
this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {});
return myPromise;
}
}
private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey);
// try each session in turn.
const decryptionErrors: Record<string, string> = {};
for (const sessionId of sessionIds) {
try {
const payload = await this.olmDevice.decryptMessage(
theirDeviceIdentityKey,
sessionId,
message.type,
message.body,
);
logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId);
return payload;
} catch (e) {
const foundSession = await this.olmDevice.matchesSession(
theirDeviceIdentityKey,
sessionId,
message.type,
message.body,
);
if (foundSession) {
// decryption failed, but it was a prekey message matching this
// session, so it should have worked.
throw new Error(
"Error decrypting prekey message with existing session id " +
sessionId +
": " +
(<Error>e).message,
);
}
// otherwise it's probably a message for another session; carry on, but
// keep a record of the error
decryptionErrors[sessionId] = (<Error>e).message;
}
}
if (message.type !== 0) {
// not a prekey message, so it should have matched an existing session, but it
// didn't work.
if (sessionIds.length === 0) {
throw new Error("No existing sessions");
}
throw new Error(
"Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors),
);
}
// prekey message which doesn't match any existing sessions: make a new
// session.
let res: IInboundSession;
try {
res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body);
} catch (e) {
decryptionErrors["(new)"] = (<Error>e).message;
throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors));
}
logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey);
return res.payload;
}
}
registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);

View File

@ -1,70 +0,0 @@
/*
Copyright 2021 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 { type DeviceInfo } from "./deviceinfo.ts";
/* re-exports for backwards compatibility. */
// CrossSigningKey is used as a value in `client.ts`, we can't export it as a type
export { CrossSigningKey } from "../crypto-api/index.ts";
export type {
GeneratedSecretStorageKey as IRecoveryKey,
CreateSecretStorageOpts as ICreateSecretStorageOpts,
} from "../crypto-api/index.ts";
export type {
ImportRoomKeyProgressData as IImportOpts,
ImportRoomKeysOpts as IImportRoomKeysOpts,
} from "../crypto-api/index.ts";
export type {
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
PassphraseInfo as IPassphraseInfo,
SecretStorageKeyDescription as ISecretStorageKeyInfo,
} from "../secret-storage.ts";
// TODO: Merge this with crypto.js once converted
export interface IEncryptedEventInfo {
/**
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
*/
encrypted: boolean;
/**
* the sender's key
*/
senderKey: string;
/**
* the algorithm used to encrypt the event
*/
algorithm: string;
/**
* whether we can be sure that the owner of the senderKey sent the event
*/
authenticated: boolean;
/**
* the sender's device information, if available
*/
sender?: DeviceInfo;
/**
* if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set)
*/
mismatchedSender: boolean;
}

View File

@ -1,922 +0,0 @@
/*
Copyright 2021 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.
*/
/**
* Classes for dealing with key backup.
*/
import type { IMegolmSessionData } from "../@types/crypto.ts";
import { MatrixClient } from "../client.ts";
import { logger } from "../logger.ts";
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib.ts";
import { type DeviceInfo } from "./deviceinfo.ts";
import { type DeviceTrustLevel } from "./CrossSigning.ts";
import { keyFromPassphrase } from "./key_passphrase.ts";
import { encodeUri, safeSet, sleep } from "../utils.ts";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store.ts";
import {
type Curve25519SessionData,
type IAes256AuthData,
type ICurve25519AuthData,
type IKeyBackupInfo,
type IKeyBackupSession,
} from "./keybackup.ts";
import { UnstableValue } from "../NamespacedValue.ts";
import { CryptoEvent } from "./index.ts";
import { ClientPrefix, type HTTPError, MatrixError, Method } from "../http-api/index.ts";
import { type BackupTrustInfo } from "../crypto-api/keybackup.ts";
import { type BackupDecryptor } from "../common-crypto/CryptoBackend.ts";
import { encodeRecoveryKey } from "../crypto-api/index.ts";
import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts";
import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts";
import { type AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts";
import { calculateKeyCheck } from "../secret-storage.ts";
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
type AuthData = IKeyBackupInfo["auth_data"];
type SigInfo = {
deviceId: string;
valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation
device?: DeviceInfo | null;
crossSigningId?: boolean;
deviceTrust?: DeviceTrustLevel;
};
/** @deprecated Prefer {@link BackupTrustInfo} */
export type TrustInfo = {
usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device
sigs: SigInfo[];
// eslint-disable-next-line camelcase
trusted_locally?: boolean;
};
export interface IKeyBackupCheck {
backupInfo?: IKeyBackupInfo;
trustInfo: TrustInfo;
}
/* eslint-disable camelcase */
export interface IPreparedKeyBackupVersion {
algorithm: string;
auth_data: AuthData;
recovery_key: string;
privateKey: Uint8Array;
}
/* eslint-enable camelcase */
/** A function used to get the secret key for a backup.
*/
type GetKey = () => Promise<ArrayLike<number>>;
interface BackupAlgorithmClass {
algorithmName: string;
// initialize from an existing backup
init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>;
// prepare a brand new backup
prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]>;
checkBackupVersion(info: IKeyBackupInfo): void;
}
interface BackupAlgorithm {
untrusted: boolean;
encryptSession(data: Record<string, any>): Promise<Curve25519SessionData | AESEncryptedSecretStoragePayload>;
decryptSessions(ciphertexts: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]>;
authData: AuthData;
keyMatches(key: ArrayLike<number>): Promise<boolean>;
free(): void;
}
export interface IKeyBackup {
rooms: {
[roomId: string]: {
sessions: {
[sessionId: string]: IKeyBackupSession;
};
};
};
}
/**
* Manages the key backup.
*/
export class BackupManager {
private algorithm: BackupAlgorithm | undefined;
public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version
public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
private sendingBackups: boolean; // Are we currently sending backups?
private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id?
// The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped
private clientRunning = true;
public constructor(
private readonly baseApis: MatrixClient,
public readonly getKey: GetKey,
) {
this.checkedForBackup = false;
this.sendingBackups = false;
}
/**
* Stop the backup manager from backing up keys and allow a clean shutdown.
*/
public stop(): void {
this.clientRunning = false;
}
public get version(): string | undefined {
return this.backupInfo && this.backupInfo.version;
}
/**
* Performs a quick check to ensure that the backup info looks sane.
*
* Throws an error if a problem is detected.
*
* @param info - the key backup info
*/
public static checkBackupVersion(info: IKeyBackupInfo): void {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm: " + info.algorithm);
}
if (typeof info.auth_data !== "object") {
throw new Error("Invalid backup data returned");
}
return Algorithm.checkBackupVersion(info);
}
public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
return Algorithm.init(info.auth_data, getKey);
}
public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
this.backupInfo = info;
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
this.baseApis.emit(CryptoEvent.KeyBackupStatus, true);
// There may be keys left over from a partially completed backup, so
// schedule a send to check.
this.scheduleKeyBackupSend();
}
/**
* Disable backing up of keys.
*/
public disableKeyBackup(): void {
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = undefined;
this.backupInfo = undefined;
this.baseApis.emit(CryptoEvent.KeyBackupStatus, false);
}
public getKeyBackupEnabled(): boolean | null {
if (!this.checkedForBackup) {
return null;
}
return Boolean(this.algorithm);
}
public async prepareKeyBackupVersion(
key?: string | Uint8Array | null,
algorithm?: string | undefined,
): Promise<IPreparedKeyBackupVersion> {
const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
const [privateKey, authData] = await Algorithm.prepare(key);
const recoveryKey = encodeRecoveryKey(privateKey)!;
return {
algorithm: Algorithm.algorithmName,
auth_data: authData,
recovery_key: recoveryKey,
privateKey,
};
}
public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<void> {
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
}
/**
* Deletes all key backups.
*
* Will call the API to delete active backup until there is no more present.
*/
public async deleteAllKeyBackupVersions(): Promise<void> {
// there could be several backup versions, delete all to be safe.
let current = (await this.baseApis.getKeyBackupVersion())?.version ?? null;
while (current != null) {
await this.deleteKeyBackupVersion(current);
this.disableKeyBackup();
current = (await this.baseApis.getKeyBackupVersion())?.version ?? null;
}
}
/**
* Deletes the given key backup.
*
* @param version - The backup version to delete.
*/
public async deleteKeyBackupVersion(version: string): Promise<void> {
const path = encodeUri("/room_keys/version/$version", { $version: version });
await this.baseApis.http.authedRequest<void>(Method.Delete, path, undefined, undefined, {
prefix: ClientPrefix.V3,
});
}
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
public async checkAndStart(): Promise<IKeyBackupCheck | null> {
logger.log("Checking key backup status...");
if (this.baseApis.isGuest()) {
logger.log("Skipping key backup check since user is guest");
this.checkedForBackup = true;
return null;
}
let backupInfo: IKeyBackupInfo | undefined;
try {
backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined;
} catch (e) {
logger.log("Error checking for active key backup", e);
if ((<HTTPError>e).httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this.checkedForBackup = true;
}
return null;
}
this.checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(`Found usable key backup v${backupInfo!.version}: enabling key backups`);
await this.enableKeyBackup(backupInfo!);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo!.version !== this.backupInfo.version) {
logger.log(
`On backup version ${this.backupInfo.version} but ` +
`found version ${backupInfo!.version}: switching.`,
);
this.disableKeyBackup();
await this.enableKeyBackup(backupInfo!);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log(`Backup version ${backupInfo!.version} still current`);
}
}
return { backupInfo, trustInfo };
}
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @returns Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
public async checkKeyBackup(): Promise<IKeyBackupCheck | null> {
this.checkedForBackup = false;
return this.checkAndStart();
}
/**
* Attempts to retrieve a session from a key backup, if enough time
* has elapsed since the last check for this session id.
*/
public async queryKeyBackupRateLimited(
targetRoomId: string | undefined,
targetSessionId: string | undefined,
): Promise<void> {
if (!this.backupInfo) {
return;
}
const now = new Date().getTime();
if (
!this.sessionLastCheckAttemptedTime[targetSessionId!] ||
now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
) {
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {});
}
}
/**
* Check if the given backup info is trusted.
*
* @param backupInfo - key backup info dict from /room_keys/version
*/
public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise<TrustInfo> {
const ret = {
usable: false,
trusted_locally: false,
sigs: [] as SigInfo[],
};
if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) {
logger.info(`Key backup is absent or missing required data: ${JSON.stringify(backupInfo)}`);
return ret;
}
const userId = this.baseApis.getUserId()!;
const privKey = await this.baseApis.crypto!.getSessionBackupPrivateKey();
if (privKey) {
let algorithm: BackupAlgorithm | null = null;
try {
algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey);
if (await algorithm.keyMatches(privKey)) {
logger.info("Backup is trusted locally");
ret.trusted_locally = true;
}
} catch {
// do nothing -- if we have an error, then we don't mark it as
// locally trusted
} finally {
algorithm?.free();
}
}
const mySigs = backupInfo.auth_data.signatures[userId] || {};
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(":");
if (keyIdParts[0] !== "ed25519") {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
const sigInfo: SigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this.baseApis.crypto!.crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await verifySignature(
this.baseApis.crypto!.olmDevice,
backupInfo.auth_data,
userId,
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warn("Bad signature from cross signing key " + crossSigningId, e);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// Now look for a sig from a device
// At some point this can probably go away and we'll just support
// it being signed by the cross-signing master key
const device = this.baseApis.crypto!.deviceList.getStoredDevice(userId, sigInfo.deviceId);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId);
try {
await verifySignature(
this.baseApis.crypto!.olmDevice,
backupInfo.auth_data,
userId,
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " +
keyId +
" userID " +
this.baseApis.getUserId() +
" device ID " +
device.deviceId +
" fingerprint: " +
device.getFingerprint(),
backupInfo.auth_data,
e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return s.valid && ((s.device && s.deviceTrust?.isVerified()) || s.crossSigningId);
});
return ret;
}
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param maxDelay - Maximum delay to wait in ms. 0 means no delay.
*/
public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
logger.debug(`Key backup: scheduleKeyBackupSend currentSending:${this.sendingBackups} delay:${maxDelay}`);
if (this.sendingBackups) return;
this.sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay);
if (!this.clientRunning) {
this.sendingBackups = false;
return;
}
let numFailures = 0; // number of consecutive failures
for (;;) {
if (!this.algorithm) {
return;
}
try {
const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
this.sendingBackups = false;
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err instanceof MatrixError) {
const errCode = err.data.errcode;
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
// Set to false now as `checkKeyBackup` might schedule a backupsend before this one ends.
this.sendingBackups = false;
// Backup version has changed or this backup version
// has been deleted
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, errCode);
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
// This call might restart the backup loop if new backup version is trusted
await this.checkKeyBackup();
return;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
if (!this.clientRunning) {
logger.debug("Key backup send loop aborted, client stopped");
this.sendingBackups = false;
return;
}
}
} catch (err) {
// No one actually checks errors on this promise, it's spawned internally.
// Just log, apps/client should use events to check status
logger.log(`Backup loop failed ${err}`);
this.sendingBackups = false;
}
}
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param limit - Maximum number of keys to back up
* @returns Number of sessions backed up
*/
public async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis.crypto!.cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
const rooms: IKeyBackup["rooms"] = {};
for (const session of sessions) {
const roomId = session.sessionData!.room_id;
safeSet(rooms, roomId, rooms[roomId] || { sessions: {} });
const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession(
session.senderKey,
session.sessionId,
session.sessionData!,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey(MEGOLM_ALGORITHM, session.senderKey);
const device =
this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(MEGOLM_ALGORITHM, session.senderKey) ??
undefined;
const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified();
safeSet(rooms[roomId]["sessions"], session.sessionId, {
first_message_index: sessionData.first_known_index,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: await this.algorithm!.encryptSession(sessionData),
});
}
await this.baseApis.sendKeyBackup(undefined, undefined, this.backupInfo!.version, { rooms });
await this.baseApis.crypto!.cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
return sessions.length;
}
public async backupGroupSession(senderKey: string, sessionId: string): Promise<void> {
await this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([
{
senderKey: senderKey,
sessionId: sessionId,
},
]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// this.enableKeyBackup is called
}
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
public async scheduleAllGroupSessionsForBackup(): Promise<void> {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
}
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns Promise which resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
public async flagAllGroupSessionsForBackup(): Promise<number> {
await this.baseApis.crypto!.cryptoStore.doTxn(
"readwrite",
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
(txn) => {
this.baseApis.crypto!.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
this.baseApis.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
return remaining;
}
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns Promise which resolves to the number of sessions requiring backup
*/
public countSessionsNeedingBackup(): Promise<number> {
return this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
}
}
export class Curve25519 implements BackupAlgorithm {
public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2";
public constructor(
public authData: ICurve25519AuthData,
private publicKey: any, // FIXME: PkEncryption
private getKey: () => Promise<Uint8Array>,
) {}
public static async init(authData: AuthData, getKey: () => Promise<Uint8Array>): Promise<Curve25519> {
if (!authData || !("public_key" in authData)) {
throw new Error("auth_data missing required information");
}
const publicKey = new globalThis.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return new Curve25519(authData as ICurve25519AuthData, publicKey, getKey);
}
public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> {
const decryption = new globalThis.Olm.PkDecryption();
try {
const authData: Partial<ICurve25519AuthData> = {};
if (!key) {
authData.public_key = decryption.generate_key();
} else if (key instanceof Uint8Array) {
authData.public_key = decryption.init_with_private_key(key);
} else {
const derivation = await keyFromPassphrase(key);
authData.private_key_salt = derivation.salt;
authData.private_key_iterations = derivation.iterations;
authData.public_key = decryption.init_with_private_key(derivation.key);
}
const publicKey = new globalThis.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return [decryption.get_private_key(), authData as AuthData];
} finally {
decryption.free();
}
}
public static checkBackupVersion(info: IKeyBackupInfo): void {
if (!("public_key" in info.auth_data)) {
throw new Error("Invalid backup data returned");
}
}
public get untrusted(): boolean {
return true;
}
public async encryptSession(data: Record<string, any>): Promise<Curve25519SessionData> {
const plainText: Record<string, any> = Object.assign({}, data);
delete plainText.session_id;
delete plainText.room_id;
delete plainText.first_known_index;
return this.publicKey.encrypt(JSON.stringify(plainText));
}
public async decryptSessions(
sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>,
): Promise<IMegolmSessionData[]> {
const privKey = await this.getKey();
const decryption = new globalThis.Olm.PkDecryption();
try {
const backupPubKey = decryption.init_with_private_key(privKey);
if (backupPubKey !== this.authData.public_key) {
throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
}
const keys: IMegolmSessionData[] = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
try {
const decrypted = JSON.parse(
decryption.decrypt(
sessionData.session_data.ephemeral,
sessionData.session_data.mac,
sessionData.session_data.ciphertext,
),
);
decrypted.session_id = sessionId;
keys.push(decrypted);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
}
return keys;
} finally {
decryption.free();
}
}
public async keyMatches(key: Uint8Array): Promise<boolean> {
const decryption = new globalThis.Olm.PkDecryption();
let pubKey: string;
try {
pubKey = decryption.init_with_private_key(key);
} finally {
decryption.free();
}
return pubKey === this.authData.public_key;
}
public free(): void {
this.publicKey.free();
}
}
function randomBytes(size: number): Uint8Array {
const buf = new Uint8Array(size);
globalThis.crypto.getRandomValues(buf);
return buf;
}
const UNSTABLE_MSC3270_NAME = new UnstableValue(
"m.megolm_backup.v1.aes-hmac-sha2",
"org.matrix.msc3270.v1.aes-hmac-sha2",
);
export class Aes256 implements BackupAlgorithm {
public static algorithmName = UNSTABLE_MSC3270_NAME.name;
public constructor(
public readonly authData: IAes256AuthData,
private readonly key: Uint8Array,
) {}
public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> {
if (!authData) {
throw new Error("auth_data missing");
}
const key = await getKey();
if (authData.mac) {
const { mac } = await calculateKeyCheck(key, authData.iv);
if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) {
throw new Error("Key does not match");
}
}
return new Aes256(authData, key);
}
public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> {
let outKey: Uint8Array;
const authData: Partial<IAes256AuthData> = {};
if (!key) {
outKey = randomBytes(32);
} else if (key instanceof Uint8Array) {
outKey = new Uint8Array(key);
} else {
const derivation = await keyFromPassphrase(key);
authData.private_key_salt = derivation.salt;
authData.private_key_iterations = derivation.iterations;
outKey = derivation.key;
}
const { iv, mac } = await calculateKeyCheck(outKey);
authData.iv = iv;
authData.mac = mac;
return [outKey, authData as AuthData];
}
public static checkBackupVersion(info: IKeyBackupInfo): void {
if (!("iv" in info.auth_data && "mac" in info.auth_data)) {
throw new Error("Invalid backup data returned");
}
}
public get untrusted(): boolean {
return false;
}
public encryptSession(data: Record<string, any>): Promise<AESEncryptedSecretStoragePayload> {
const plainText: Record<string, any> = Object.assign({}, data);
delete plainText.session_id;
delete plainText.room_id;
delete plainText.first_known_index;
return encryptAESSecretStorageItem(JSON.stringify(plainText), this.key, data.session_id);
}
public async decryptSessions(
sessions: Record<string, IKeyBackupSession<AESEncryptedSecretStoragePayload>>,
): Promise<IMegolmSessionData[]> {
const keys: IMegolmSessionData[] = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
try {
const decrypted = JSON.parse(
await decryptAESSecretStorageItem(sessionData.session_data, this.key, sessionId),
);
decrypted.session_id = sessionId;
keys.push(decrypted);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
}
return keys;
}
public async keyMatches(key: Uint8Array): Promise<boolean> {
if (this.authData.mac) {
const { mac } = await calculateKeyCheck(key, this.authData.iv);
return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, "");
} else {
// if we have no information, we have to assume the key is right
return true;
}
}
public free(): void {
this.key.fill(0);
}
}
export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
[Curve25519.algorithmName]: Curve25519,
[Aes256.algorithmName]: Aes256,
};
// the linter doesn't like this but knip does
// eslint-disable-next-line tsdoc/syntax
/** @alias */
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
/**
* Map a legacy {@link TrustInfo} into a new-style {@link BackupTrustInfo}.
*
* @param trustInfo - trustInfo to convert
*/
export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): BackupTrustInfo {
return {
trusted: trustInfo.usable,
matchesDecryptionKey: trustInfo.trusted_locally ?? false,
};
}
/**
* Implementation of {@link BackupDecryptor} for the libolm crypto backend.
*/
export class LibOlmBackupDecryptor implements BackupDecryptor {
private algorithm: BackupAlgorithm;
public readonly sourceTrusted: boolean;
public constructor(algorithm: BackupAlgorithm) {
this.algorithm = algorithm;
this.sourceTrusted = !algorithm.untrusted;
}
/**
* Implements {@link BackupDecryptor#free}
*/
public free(): void {
this.algorithm.free();
}
/**
* Implements {@link BackupDecryptor#decryptSessions}
*/
public async decryptSessions(
sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>,
): Promise<IMegolmSessionData[]> {
return await this.algorithm.decryptSessions(sessions);
}
}

View File

@ -1,18 +0,0 @@
/*
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.
*/
/** @deprecated this is a no-op and should no longer be called. */
export function setCrypto(_crypto: Crypto): void {}

View File

@ -1,272 +0,0 @@
/*
Copyright 2020-2021 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 anotherjson from "another-json";
import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store.ts";
import { logger } from "../logger.ts";
import { type Crypto } from "./index.ts";
import { Method } from "../http-api/index.ts";
import { type SecretStorageKeyDescription } from "../secret-storage.ts";
import decryptAESSecretStorageItem from "../utils/decryptAESSecretStorageItem.ts";
import encryptAESSecretStorageItem from "../utils/encryptAESSecretStorageItem.ts";
export interface IDehydratedDevice {
device_id?: string; // eslint-disable-line camelcase
device_data?: SecretStorageKeyDescription & {
// eslint-disable-line camelcase
algorithm: string;
account: string; // pickle
};
}
export interface IDehydratedDeviceKeyInfo {
passphrase?: string;
}
export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle";
const oneweek = 7 * 24 * 60 * 60 * 1000;
export class DehydrationManager {
private inProgress = false;
private timeoutId: any;
private key?: Uint8Array;
private keyInfo?: { [props: string]: any };
private deviceDisplayName?: string;
public constructor(private readonly crypto: Crypto) {
this.getDehydrationKeyFromCache();
}
public getDehydrationKeyFromCache(): Promise<void> {
return this.crypto.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
this.crypto.cryptoStore.getSecretStorePrivateKey(
txn,
async (result) => {
if (result) {
const { key, keyInfo, deviceDisplayName, time } = result;
const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey);
const decrypted = await decryptAESSecretStorageItem(key, pickleKey, DEHYDRATION_ALGORITHM);
this.key = decodeBase64(decrypted);
this.keyInfo = keyInfo;
this.deviceDisplayName = deviceDisplayName;
const now = Date.now();
const delay = Math.max(1, time + oneweek - now);
this.timeoutId = globalThis.setTimeout(this.dehydrateDevice.bind(this), delay);
}
},
"dehydration",
);
});
}
/** set the key, and queue periodic dehydration to the server in the background */
public async setKeyAndQueueDehydration(
key: Uint8Array,
keyInfo: { [props: string]: any } = {},
deviceDisplayName?: string,
): Promise<void> {
const matches = await this.setKey(key, keyInfo, deviceDisplayName);
if (!matches) {
// start dehydration in the background
this.dehydrateDevice();
}
}
public async setKey(
key?: Uint8Array,
keyInfo: { [props: string]: any } = {},
deviceDisplayName?: string,
): Promise<boolean | undefined> {
if (!key) {
// unsetting the key -- cancel any pending dehydration task
if (this.timeoutId) {
globalThis.clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
// clear storage
await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null);
});
this.key = undefined;
this.keyInfo = undefined;
return;
}
// Check to see if it's the same key as before. If it's different,
// dehydrate a new device. If it's the same, we can keep the same
// device. (Assume that keyInfo and deviceDisplayName will be the
// same if the key is the same.)
let matches: boolean = !!this.key && key.length == this.key.length;
for (let i = 0; matches && i < key.length; i++) {
if (key[i] != this.key![i]) {
matches = false;
}
}
if (!matches) {
this.key = key;
this.keyInfo = keyInfo;
this.deviceDisplayName = deviceDisplayName;
}
return matches;
}
/** returns the device id of the newly created dehydrated device */
public async dehydrateDevice(): Promise<string | undefined> {
if (this.inProgress) {
logger.log("Dehydration already in progress -- not starting new dehydration");
return;
}
this.inProgress = true;
if (this.timeoutId) {
globalThis.clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
try {
const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey);
// update the crypto store with the timestamp
const key = await encryptAESSecretStorageItem(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM);
await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", {
keyInfo: this.keyInfo,
key,
deviceDisplayName: this.deviceDisplayName!,
time: Date.now(),
});
});
logger.log("Attempting to dehydrate device");
logger.log("Creating account");
// create the account and all the necessary keys
const account = new globalThis.Olm.Account();
account.create();
const e2eKeys = JSON.parse(account.identity_keys());
const maxKeys = account.max_number_of_one_time_keys();
// FIXME: generate in small batches?
account.generate_one_time_keys(maxKeys / 2);
account.generate_fallback_key();
const otks: Record<string, string> = JSON.parse(account.one_time_keys());
const fallbacks: Record<string, string> = JSON.parse(account.fallback_key());
account.mark_keys_as_published();
// dehydrate the account and store it on the server
const pickledAccount = account.pickle(new Uint8Array(this.key!));
const deviceData: { [props: string]: any } = {
algorithm: DEHYDRATION_ALGORITHM,
account: pickledAccount,
};
if (this.keyInfo!.passphrase) {
deviceData.passphrase = this.keyInfo!.passphrase;
}
logger.log("Uploading account to server");
// eslint-disable-next-line camelcase
const dehydrateResult = await this.crypto.baseApis.http.authedRequest<{ device_id: string }>(
Method.Put,
"/dehydrated_device",
undefined,
{
device_data: deviceData,
initial_device_display_name: this.deviceDisplayName,
},
{
prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
},
);
// send the keys to the server
const deviceId = dehydrateResult.device_id;
logger.log("Preparing device keys", deviceId);
const deviceKeys: IDeviceKeys = {
algorithms: this.crypto.supportedAlgorithms,
device_id: deviceId,
user_id: this.crypto.userId,
keys: {
[`ed25519:${deviceId}`]: e2eKeys.ed25519,
[`curve25519:${deviceId}`]: e2eKeys.curve25519,
},
};
const deviceSignature = account.sign(anotherjson.stringify(deviceKeys));
deviceKeys.signatures = {
[this.crypto.userId]: {
[`ed25519:${deviceId}`]: deviceSignature,
},
};
if (this.crypto.crossSigningInfo.getId("self_signing")) {
await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing");
}
logger.log("Preparing one-time keys");
const oneTimeKeys: Record<string, IOneTimeKey> = {};
for (const [keyId, key] of Object.entries(otks.curve25519)) {
const k: IOneTimeKey = { key };
const signature = account.sign(anotherjson.stringify(k));
k.signatures = {
[this.crypto.userId]: {
[`ed25519:${deviceId}`]: signature,
},
};
oneTimeKeys[`signed_curve25519:${keyId}`] = k;
}
logger.log("Preparing fallback keys");
const fallbackKeys: Record<string, IOneTimeKey> = {};
for (const [keyId, key] of Object.entries(fallbacks.curve25519)) {
const k: IOneTimeKey = { key, fallback: true };
const signature = account.sign(anotherjson.stringify(k));
k.signatures = {
[this.crypto.userId]: {
[`ed25519:${deviceId}`]: signature,
},
};
fallbackKeys[`signed_curve25519:${keyId}`] = k;
}
logger.log("Uploading keys to server");
await this.crypto.baseApis.http.authedRequest(
Method.Post,
"/keys/upload/" + encodeURI(deviceId),
undefined,
{
"device_keys": deviceKeys,
"one_time_keys": oneTimeKeys,
"org.matrix.msc2732.fallback_keys": fallbackKeys,
},
);
logger.log("Done dehydrating");
// dehydrate again in a week
this.timeoutId = globalThis.setTimeout(this.dehydrateDevice.bind(this), oneweek);
return deviceId;
} finally {
this.inProgress = false;
}
}
public stop(): void {
if (this.timeoutId) {
globalThis.clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
}
}

View File

@ -1,45 +0,0 @@
/*
Copyright 2023 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 { Device } from "../models/device.ts";
import { type DeviceInfo } from "./deviceinfo.ts";
/**
* Convert a {@link DeviceInfo} to a {@link Device}.
* @param deviceInfo - deviceInfo to convert
* @param userId - id of the user that owns the device.
*/
export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device {
const keys = new Map<string, string>(Object.entries(deviceInfo.keys));
const displayName = deviceInfo.getDisplayName() || undefined;
const signatures = new Map<string, Map<string, string>>();
if (deviceInfo.signatures) {
for (const userId in deviceInfo.signatures) {
signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId])));
}
}
return new Device({
deviceId: deviceInfo.deviceId,
userId: userId,
keys,
algorithms: deviceInfo.algorithms,
verified: deviceInfo.verified,
signatures,
displayName,
});
}

View File

@ -1,158 +0,0 @@
/*
Copyright 2016 - 2021 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 { type ISignatures } from "../@types/signed.ts";
import { DeviceVerification } from "../models/device.ts";
export interface IDevice {
keys: Record<string, string>;
algorithms: string[];
verified: DeviceVerification;
known: boolean;
unsigned?: Record<string, any>;
signatures?: ISignatures;
}
/**
* Information about a user's device
*
* Superceded by {@link Device}.
*/
export class DeviceInfo {
/**
* rehydrate a DeviceInfo from the session store
*
* @param obj - raw object from session store
* @param deviceId - id of the device
*
* @returns new DeviceInfo
*/
public static fromStorage(obj: Partial<IDevice>, deviceId: string): DeviceInfo {
const res = new DeviceInfo(deviceId);
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
// @ts-ignore - this is messy and typescript doesn't like it
res[prop as keyof IDevice] = obj[prop as keyof IDevice];
}
}
return res;
}
public static DeviceVerification = {
VERIFIED: DeviceVerification.Verified,
UNVERIFIED: DeviceVerification.Unverified,
BLOCKED: DeviceVerification.Blocked,
};
/** list of algorithms supported by this device */
public algorithms: string[] = [];
/** a map from `<key type>:<id> -> <base64-encoded key>` */
public keys: Record<string, string> = {};
/** whether the device has been verified/blocked by the user */
public verified = DeviceVerification.Unverified;
/**
* whether the user knows of this device's existence
* (useful when warning the user that a user has added new devices)
*/
public known = false;
/** additional data from the homeserver */
public unsigned: Record<string, any> = {};
public signatures: ISignatures = {};
/**
* @param deviceId - id of the device
*/
public constructor(public readonly deviceId: string) {}
/**
* Prepare a DeviceInfo for JSON serialisation in the session store
*
* @returns deviceinfo with non-serialised members removed
*/
public toStorage(): IDevice {
return {
algorithms: this.algorithms,
keys: this.keys,
verified: this.verified,
known: this.known,
unsigned: this.unsigned,
signatures: this.signatures,
};
}
/**
* Get the fingerprint for this device (ie, the Ed25519 key)
*
* @returns base64-encoded fingerprint of this device
*/
public getFingerprint(): string {
return this.keys["ed25519:" + this.deviceId];
}
/**
* Get the identity key for this device (ie, the Curve25519 key)
*
* @returns base64-encoded identity key of this device
*/
public getIdentityKey(): string {
return this.keys["curve25519:" + this.deviceId];
}
/**
* Get the configured display name for this device, if any
*
* @returns displayname
*/
public getDisplayName(): string | null {
return this.unsigned.device_display_name || null;
}
/**
* Returns true if this device is blocked
*
* @returns true if blocked
*/
public isBlocked(): boolean {
return this.verified == DeviceVerification.Blocked;
}
/**
* Returns true if this device is verified
*
* @returns true if verified
*/
public isVerified(): boolean {
return this.verified == DeviceVerification.Verified;
}
/**
* Returns true if this device is unverified
*
* @returns true if unverified
*/
public isUnverified(): boolean {
return this.verified == DeviceVerification.Unverified;
}
/**
* Returns true if the user knows about this device's existence
*
* @returns true if known
*/
public isKnown(): boolean {
return this.known === true;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +0,0 @@
/*
Copyright 2018 - 2021 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 { secureRandomString } from "../randomstring.ts";
import { deriveRecoveryKeyFromPassphrase } from "../crypto-api/index.ts";
const DEFAULT_ITERATIONS = 500000;
interface IKey {
key: Uint8Array;
salt: string;
iterations: number;
}
/**
* Generate a new recovery key, based on a passphrase.
* @param passphrase - The passphrase to generate the key from
*/
export async function keyFromPassphrase(passphrase: string): Promise<IKey> {
const salt = secureRandomString(32);
const key = await deriveRecoveryKeyFromPassphrase(passphrase, salt, DEFAULT_ITERATIONS);
return { key, salt, iterations: DEFAULT_ITERATIONS };
}
// Re-export the key passphrase functions to avoid breaking changes
export { deriveRecoveryKeyFromPassphrase as deriveKey };
export { keyFromAuthData } from "../common-crypto/key-passphrase.ts";

View File

@ -1,47 +0,0 @@
/*
Copyright 2021 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.
*/
// Export for backward compatibility
import { type ImportRoomKeyProgressData } from "../crypto-api/index.ts";
export type {
Curve25519AuthData as ICurve25519AuthData,
Aes256AuthData as IAes256AuthData,
KeyBackupInfo as IKeyBackupInfo,
Curve25519SessionData,
KeyBackupSession as IKeyBackupSession,
KeyBackupRoomSessions as IKeyBackupRoomSessions,
} from "../crypto-api/keybackup.ts";
/* eslint-enable camelcase */
export interface IKeyBackupPrepareOpts {
/**
* Whether to use Secure Secret Storage to store the key encrypting key backups.
* Optional, defaults to false.
*/
secureSecretStorage: boolean;
}
export interface IKeyBackupRestoreResult {
total: number;
imported: number;
}
export interface IKeyBackupRestoreOpts {
cacheCompleteCallback?: () => void;
progressCallback?: (progress: ImportRoomKeyProgressData) => void;
}

View File

@ -1,539 +0,0 @@
/*
Copyright 2016 - 2021 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.
*/
/**
* Utilities common to olm encryption algorithms
*/
import anotherjson from "another-json";
import type { PkSigning } from "@matrix-org/olm";
import type { IOneTimeKey } from "../@types/crypto.ts";
import { type OlmDevice } from "./OlmDevice.ts";
import { type DeviceInfo } from "./deviceinfo.ts";
import { type Logger, logger } from "../logger.ts";
import { type IClaimOTKsResult, type MatrixClient } from "../client.ts";
import { type ISignatures } from "../@types/signed.ts";
import { type MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
import { type IMessage } from "./algorithms/olm.ts";
import { MapWithDefault } from "../utils.ts";
enum Algorithm {
Olm = "m.olm.v1.curve25519-aes-sha2",
Megolm = "m.megolm.v1.aes-sha2",
MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2",
}
/**
* matrix algorithm tag for olm
*/
export const OLM_ALGORITHM = Algorithm.Olm;
/**
* matrix algorithm tag for megolm
*/
export const MEGOLM_ALGORITHM = Algorithm.Megolm;
/**
* matrix algorithm tag for megolm backups
*/
export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup;
export interface IOlmSessionResult {
/** device info */
device: DeviceInfo;
/** base64 olm session id; null if no session could be established */
sessionId: string | null;
}
/**
* Encrypt an event payload for an Olm device
*
* @param resultsObject - The `ciphertext` property
* of the m.room.encrypted event to which to add our result
*
* @param olmDevice - olm.js wrapper
* @param payloadFields - fields to include in the encrypted payload
*
* Returns a promise which resolves (to undefined) when the payload
* has been encrypted into `resultsObject`
*/
export async function encryptMessageForDevice(
resultsObject: Record<string, IMessage>,
ourUserId: string,
ourDeviceId: string | undefined,
olmDevice: OlmDevice,
recipientUserId: string,
recipientDevice: DeviceInfo,
payloadFields: Record<string, any>,
): Promise<void> {
const deviceKey = recipientDevice.getIdentityKey();
const sessionId = await olmDevice.getSessionIdForDevice(deviceKey);
if (sessionId === null) {
// If we don't have a session for a device then
// we can't encrypt a message for it.
logger.log(
`[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` +
`${recipientUserId}:${recipientDevice.deviceId}`,
);
return;
}
logger.log(
`[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` +
`${recipientUserId}:${recipientDevice.deviceId}`,
);
const payload = {
sender: ourUserId,
// TODO this appears to no longer be used whatsoever
sender_device: ourDeviceId,
// Include the Ed25519 key so that the recipient knows what
// device this message came from.
// We don't need to include the curve25519 key since the
// recipient will already know this from the olm headers.
// When combined with the device keys retrieved from the
// homeserver signed by the ed25519 key this proves that
// the curve25519 key and the ed25519 key are owned by
// the same device.
keys: {
ed25519: olmDevice.deviceEd25519Key,
},
// include the recipient device details in the payload,
// to avoid unknown key attacks, per
// https://github.com/vector-im/vector-web/issues/2483
recipient: recipientUserId,
recipient_keys: {
ed25519: recipientDevice.getFingerprint(),
},
...payloadFields,
};
// TODO: technically, a bunch of that stuff only needs to be included for
// pre-key messages: after that, both sides know exactly which devices are
// involved in the session. If we're looking to reduce data transfer in the
// future, we could elide them for subsequent messages.
resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload));
}
interface IExistingOlmSession {
device: DeviceInfo;
sessionId: string | null;
}
/**
* Get the existing olm sessions for the given devices, and the devices that
* don't have olm sessions.
*
*
*
* @param devicesByUser - map from userid to list of devices to ensure sessions for
*
* @returns resolves to an array. The first element of the array is a
* a map of user IDs to arrays of deviceInfo, representing the devices that
* don't have established olm sessions. The second element of the array is
* a map from userId to deviceId to {@link OlmSessionResult}
*/
export async function getExistingOlmSessions(
olmDevice: OlmDevice,
baseApis: MatrixClient,
devicesByUser: Record<string, DeviceInfo[]>,
): Promise<[Map<string, DeviceInfo[]>, Map<string, Map<string, IExistingOlmSession>>]> {
// map user Id → DeviceInfo[]
const devicesWithoutSession: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []);
// map user Id → device Id → IExistingOlmSession
const sessions: MapWithDefault<string, Map<string, IExistingOlmSession>> = new MapWithDefault(() => new Map());
const promises: Promise<void>[] = [];
for (const [userId, devices] of Object.entries(devicesByUser)) {
for (const deviceInfo of devices) {
const deviceId = deviceInfo.deviceId;
const key = deviceInfo.getIdentityKey();
promises.push(
(async (): Promise<void> => {
const sessionId = await olmDevice.getSessionIdForDevice(key, true);
if (sessionId === null) {
devicesWithoutSession.getOrCreate(userId).push(deviceInfo);
} else {
sessions.getOrCreate(userId).set(deviceId, {
device: deviceInfo,
sessionId: sessionId,
});
}
})(),
);
}
}
await Promise.all(promises);
return [devicesWithoutSession, sessions];
}
/**
* Try to make sure we have established olm sessions for the given devices.
*
* @param devicesByUser - map from userid to list of devices to ensure sessions for
*
* @param force - If true, establish a new session even if one
* already exists.
*
* @param otkTimeout - The timeout in milliseconds when requesting
* one-time keys for establishing new olm sessions.
*
* @param failedServers - An array to fill with remote servers that
* failed to respond to one-time-key requests.
*
* @param log - A possibly customised log
*
* @returns resolves once the sessions are complete, to
* an Object mapping from userId to deviceId to
* {@link OlmSessionResult}
*/
export async function ensureOlmSessionsForDevices(
olmDevice: OlmDevice,
baseApis: MatrixClient,
devicesByUser: Map<string, DeviceInfo[]>,
force = false,
otkTimeout?: number,
failedServers?: string[],
log: Logger = logger,
): Promise<Map<string, Map<string, IOlmSessionResult>>> {
const devicesWithoutSession: [string, string][] = [
// [userId, deviceId], ...
];
// map user Id → device Id → IExistingOlmSession
const result: Map<string, Map<string, IExistingOlmSession>> = new Map();
// map device key → resolve session fn
const resolveSession: Map<string, (sessionId?: string) => void> = new Map();
// Mark all sessions this task intends to update as in progress. It is
// important to do this for all devices this task cares about in a single
// synchronous operation, as otherwise it is possible to have deadlocks
// where multiple tasks wait indefinitely on another task to update some set
// of common devices.
for (const devices of devicesByUser.values()) {
for (const deviceInfo of devices) {
const key = deviceInfo.getIdentityKey();
if (key === olmDevice.deviceCurve25519Key) {
// We don't start sessions with ourself, so there's no need to
// mark it in progress.
continue;
}
if (!olmDevice.sessionsInProgress[key]) {
// pre-emptively mark the session as in-progress to avoid race
// conditions. If we find that we already have a session, then
// we'll resolve
olmDevice.sessionsInProgress[key] = new Promise((resolve) => {
resolveSession.set(key, (v: any): void => {
delete olmDevice.sessionsInProgress[key];
resolve(v);
});
});
}
}
}
for (const [userId, devices] of devicesByUser) {
const resultDevices = new Map();
result.set(userId, resultDevices);
for (const deviceInfo of devices) {
const deviceId = deviceInfo.deviceId;
const key = deviceInfo.getIdentityKey();
if (key === olmDevice.deviceCurve25519Key) {
// We should never be trying to start a session with ourself.
// Apart from talking to yourself being the first sign of madness,
// olm sessions can't do this because they get confused when
// they get a message and see that the 'other side' has started a
// new chain when this side has an active sender chain.
// If you see this message being logged in the wild, we should find
// the thing that is trying to send Olm messages to itself and fix it.
log.info("Attempted to start session with ourself! Ignoring");
// We must fill in the section in the return value though, as callers
// expect it to be there.
resultDevices.set(deviceId, {
device: deviceInfo,
sessionId: null,
});
continue;
}
const forWhom = `for ${key} (${userId}:${deviceId})`;
const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession.get(key), log);
const resolveSessionFn = resolveSession.get(key);
if (sessionId !== null && resolveSessionFn) {
// we found a session, but we had marked the session as
// in-progress, so resolve it now, which will unmark it and
// unblock anything that was waiting
resolveSessionFn();
}
if (sessionId === null || force) {
if (force) {
log.info(`Forcing new Olm session ${forWhom}`);
} else {
log.info(`Making new Olm session ${forWhom}`);
}
devicesWithoutSession.push([userId, deviceId]);
}
resultDevices.set(deviceId, {
device: deviceInfo,
sessionId: sessionId,
});
}
}
if (devicesWithoutSession.length === 0) {
return result;
}
const oneTimeKeyAlgorithm = "signed_curve25519";
let res: IClaimOTKsResult;
let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`;
try {
log.debug(`Claiming ${taskDetail}`);
res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout);
log.debug(`Claimed ${taskDetail}`);
} catch (e) {
for (const resolver of resolveSession.values()) {
resolver();
}
log.debug(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
throw e;
}
if (failedServers && "failures" in res) {
failedServers.push(...Object.keys(res.failures));
}
const otkResult = res.one_time_keys || ({} as IClaimOTKsResult["one_time_keys"]);
const promises: Promise<void>[] = [];
for (const [userId, devices] of devicesByUser) {
const userRes = otkResult[userId] || {};
for (const deviceInfo of devices) {
const deviceId = deviceInfo.deviceId;
const key = deviceInfo.getIdentityKey();
if (key === olmDevice.deviceCurve25519Key) {
// We've already logged about this above. Skip here too
// otherwise we'll log saying there are no one-time keys
// which will be confusing.
continue;
}
if (result.get(userId)?.get(deviceId)?.sessionId && !force) {
// we already have a result for this device
continue;
}
const deviceRes = userRes[deviceId] || {};
let oneTimeKey: IOneTimeKey | null = null;
for (const keyId in deviceRes) {
if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) {
oneTimeKey = deviceRes[keyId];
}
}
if (!oneTimeKey) {
log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`);
resolveSession.get(key)?.();
continue;
}
promises.push(
_verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then(
(sid) => {
resolveSession.get(key)?.(sid ?? undefined);
const deviceInfo = result.get(userId)?.get(deviceId);
if (deviceInfo) deviceInfo.sessionId = sid;
},
(e) => {
resolveSession.get(key)?.();
throw e;
},
),
);
}
}
taskDetail = `Olm sessions for ${promises.length} devices`;
log.debug(`Starting ${taskDetail}`);
await Promise.all(promises);
log.debug(`Started ${taskDetail}`);
return result;
}
async function _verifyKeyAndStartSession(
olmDevice: OlmDevice,
oneTimeKey: IOneTimeKey,
userId: string,
deviceInfo: DeviceInfo,
): Promise<string | null> {
const deviceId = deviceInfo.deviceId;
try {
await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint());
} catch (e) {
logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e);
return null;
}
let sid;
try {
sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key);
} catch (e) {
// possibly a bad key
logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e);
return null;
}
logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId);
return sid;
}
export interface IObject {
unsigned?: object;
signatures?: ISignatures;
}
/**
* Verify the signature on an object
*
* @param olmDevice - olm wrapper to use for verify op
*
* @param obj - object to check signature on.
*
* @param signingUserId - ID of the user whose signature should be checked
*
* @param signingDeviceId - ID of the device whose signature should be checked
*
* @param signingKey - base64-ed ed25519 public key
*
* Returns a promise which resolves (to undefined) if the the signature is good,
* or rejects with an Error if it is bad.
*/
export async function verifySignature(
olmDevice: OlmDevice,
obj: IOneTimeKey | IObject,
signingUserId: string,
signingDeviceId: string,
signingKey: string,
): Promise<void> {
const signKeyId = "ed25519:" + signingDeviceId;
const signatures = obj.signatures || {};
const userSigs = signatures[signingUserId] || {};
const signature = userSigs[signKeyId];
if (!signature) {
throw Error("No signature");
}
// prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson
const mangledObj = Object.assign({}, obj);
if ("unsigned" in mangledObj) {
delete mangledObj.unsigned;
}
delete mangledObj.signatures;
const json = anotherjson.stringify(mangledObj);
olmDevice.verifySignature(signingKey, json, signature);
}
/**
* Sign a JSON object using public key cryptography
* @param obj - Object to sign. The object will be modified to include
* the new signature
* @param key - the signing object or the private key
* seed
* @param userId - The user ID who owns the signing key
* @param pubKey - The public key (ignored if key is a seed)
* @returns the signature for the object
*/
export function pkSign(obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string {
let createdKey = false;
if (key instanceof Uint8Array) {
const keyObj = new globalThis.Olm.PkSigning();
pubKey = keyObj.init_with_seed(key);
key = keyObj;
createdKey = true;
}
const sigs = obj.signatures || {};
delete obj.signatures;
const unsigned = obj.unsigned;
if (obj.unsigned) delete obj.unsigned;
try {
const mysigs = sigs[userId] || {};
sigs[userId] = mysigs;
return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj)));
} finally {
obj.signatures = sigs;
if (unsigned) obj.unsigned = unsigned;
if (createdKey) {
key.free();
}
}
}
/**
* Verify a signed JSON object
* @param obj - Object to verify
* @param pubKey - The public key to use to verify
* @param userId - The user ID who signed the object
*/
export function pkVerify(obj: IObject, pubKey: string, userId: string): void {
const keyId = "ed25519:" + pubKey;
if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) {
throw new Error("No signature");
}
const signature = obj.signatures[userId][keyId];
const util = new globalThis.Olm.Utility();
const sigs = obj.signatures;
delete obj.signatures;
const unsigned = obj.unsigned;
if (obj.unsigned) delete obj.unsigned;
try {
util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature);
} finally {
obj.signatures = sigs;
if (unsigned) obj.unsigned = unsigned;
util.free();
}
}
/**
* Check that an event was encrypted using olm.
*/
export function isOlmEncrypted(event: MatrixEvent): boolean {
if (!event.getSenderKey()) {
logger.error("Event has no sender key (not encrypted?)");
return false;
}
if (
event.getWireType() !== EventType.RoomMessageEncrypted ||
!["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm)
) {
logger.error("Event was not encrypted using an appropriate algorithm");
return false;
}
return true;
}

View File

@ -1,18 +0,0 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
// Re-export to avoid breaking changes
export * from "../crypto-api/recovery-key.ts";

View File

@ -14,31 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IRoomKeyRequestBody, type IRoomKeyRequestRecipient } from "../index.ts";
import { type RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager.ts";
import { type IOlmDevice } from "../algorithms/megolm.ts";
import { type TrackingStatus } from "../DeviceList.ts";
import { type IRoomEncryption } from "../RoomList.ts";
import { type IDevice } from "../deviceinfo.ts";
import { type ICrossSigningInfo } from "../CrossSigning.ts";
import { type Logger } from "../../logger.ts";
import { type InboundGroupSessionData } from "../OlmDevice.ts";
import { type MatrixEvent } from "../../models/event.ts";
import { type DehydrationManager } from "../dehydration.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
import { type AESEncryptedSecretStoragePayload } from "../../@types/AESEncryptedSecretStoragePayload.ts";
import { type ISignatures } from "../../@types/signed.ts";
/**
* Internal module. Definitions for storage for the crypto module
*/
export interface SecretStorePrivateKeys {
"dehydration": {
keyInfo: DehydrationManager["keyInfo"];
key: AESEncryptedSecretStoragePayload;
deviceDisplayName: string;
time: number;
} | null;
"m.megolm_backup.v1": AESEncryptedSecretStoragePayload;
}
@ -81,22 +66,6 @@ export interface CryptoStore {
*/
setMigrationState(migrationState: MigrationState): Promise<void>;
getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>;
getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>;
getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>;
getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]>;
getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]>;
updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null>;
deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise<OutgoingRoomKeyRequest | null>;
// Olm Account
getAccount(txn: unknown, func: (accountPickle: string | null) => void): void;
storeAccount(txn: unknown, accountPickle: string): void;
@ -106,7 +75,6 @@ export interface CryptoStore {
func: (key: SecretStorePrivateKeys[K] | null) => void,
type: K,
): void;
storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void;
storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@ -126,11 +94,8 @@ export interface CryptoStore {
txn: unknown,
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
): void;
getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void;
storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void;
storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void>;
getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>;
filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>;
/**
* Get a batch of end-to-end sessions from the database.
@ -156,25 +121,12 @@ export interface CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void;
getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void;
addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void;
storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void;
storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void;
/**
* Count the number of Megolm sessions in the database.
@ -201,21 +153,8 @@ export interface CryptoStore {
deleteEndToEndInboundGroupSessionsBatch(sessions: { senderKey: string; sessionId: string }[]): Promise<void>;
// Device Data
getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void;
storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void;
storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void;
getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void;
getSessionsNeedingBackup(limit: number): Promise<ISession[]>;
countSessionsNeedingBackup(txn?: unknown): Promise<number>;
unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void;
getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: unknown,
): Promise<[senderKey: string, sessionId: string][]>;
addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void;
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
// Session key backups
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: Logger): Promise<T>;
@ -256,12 +195,6 @@ export interface IDeviceData {
syncToken?: string;
}
export interface IProblem {
type: string;
fixed: boolean;
time: number;
}
export interface IWithheld {
// eslint-disable-next-line camelcase
room_id: string;
@ -297,15 +230,6 @@ export interface OutgoingRoomKeyRequest {
state: RoomKeyRequestState;
}
export interface ParkedSharedHistory {
senderId: string;
senderKey: string;
sessionId: string;
sessionKey: string;
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
forwardingCurve25519KeyChain: string[];
}
/**
* Keys for the `account` object store to store the migration state.
* Values are defined in `MigrationState`.
@ -346,3 +270,119 @@ export enum MigrationState {
* {@link CryptoStore#getEndToEndInboundGroupSessionsBatch}.
*/
export const SESSION_BATCH_SIZE = 50;
export interface InboundGroupSessionData {
room_id: string; // eslint-disable-line camelcase
/** pickled Olm.InboundGroupSession */
session: string;
keysClaimed?: Record<string, string>;
/** Devices involved in forwarding this session to us (normally empty). */
forwardingCurve25519KeyChain: string[];
/** whether this session is untrusted. */
untrusted?: boolean;
/** whether this session exists during the room being set to shared history. */
sharedHistory?: boolean;
}
export interface ICrossSigningInfo {
keys: Record<string, CrossSigningKeyInfo>;
firstUse: boolean;
crossSigningVerifiedBefore: boolean;
}
/* eslint-disable camelcase */
export interface IRoomEncryption {
algorithm: string;
rotation_period_ms?: number;
rotation_period_msgs?: number;
}
/* eslint-enable camelcase */
export enum TrackingStatus {
NotTracked,
PendingDownload,
DownloadInProgress,
UpToDate,
}
/**
* possible states for a room key request
*
* The state machine looks like:
* ```
*
* | (cancellation sent)
* | .-------------------------------------------------.
* | | |
* V V (cancellation requested) |
* UNSENT -----------------------------+ |
* | | |
* | | |
* | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND
* V | Λ
* SENT | |
* |-------------------------------- | --------------'
* | | (cancellation requested with intent
* | | to resend the original request)
* | |
* | (cancellation requested) |
* V |
* CANCELLATION_PENDING |
* | |
* | (cancellation sent) |
* V |
* (deleted) <---------------------------+
* ```
*/
export enum RoomKeyRequestState {
/** request not yet sent */
Unsent,
/** request sent, awaiting reply */
Sent,
/** reply received, cancellation not yet sent */
CancellationPending,
/**
* Cancellation not yet sent and will transition to UNSENT instead of
* being deleted once the cancellation has been sent.
*/
CancellationPendingAndWillResend,
}
/* eslint-disable camelcase */
interface IRoomKey {
room_id: string;
algorithm: string;
}
/**
* The parameters of a room key request. The details of the request may
* vary with the crypto algorithm, but the management and storage layers for
* outgoing requests expect it to have 'room_id' and 'session_id' properties.
*/
export interface IRoomKeyRequestBody extends IRoomKey {
session_id: string;
sender_key: string;
}
/* eslint-enable camelcase */
export interface IRoomKeyRequestRecipient {
userId: string;
deviceId: string;
}
interface IDevice {
keys: Record<string, string>;
algorithms: string[];
verified: DeviceVerification;
known: boolean;
unsigned?: Record<string, any>;
signatures?: ISignatures;
}
/** State of the verification of the device. */
export enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}

View File

@ -15,27 +15,21 @@ limitations under the License.
*/
import { type Logger, logger } from "../../logger.ts";
import { deepCompare } from "../../utils.ts";
import {
type CryptoStore,
type IDeviceData,
type IProblem,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type OutgoingRoomKeyRequest,
type ParkedSharedHistory,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type IRoomKeyRequestBody, type IRoomKeyRequestRecipient } from "../index.ts";
import { type IOlmDevice } from "../algorithms/megolm.ts";
import { type IRoomEncryption } from "../RoomList.ts";
import { type InboundGroupSessionData } from "../OlmDevice.ts";
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
@ -106,297 +100,6 @@ export class Backend implements CryptoStore {
});
}
/**
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
txn.onerror = reject;
// first see if we already have an entry for this request.
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
if (existing) {
// this entry matches the request - return it.
logger.log(
`already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`,
);
resolve(existing);
return;
}
// we got to the end of the list without finding a match
// - add the new request.
logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
txn.oncomplete = (): void => {
resolve(request);
};
const store = txn.objectStore("outgoingRoomKeyRequests");
store.add(request);
});
});
}
/**
* Look for an existing room key request
*
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
txn.onerror = reject;
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
resolve(existing);
});
});
}
/**
* look for an existing room key request in the db
*
* @internal
* @param txn - database transaction
* @param requestBody - existing request to look for
* @param callback - function to call with the results of the
* search. Either passed a matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(
txn: IDBTransaction,
requestBody: IRoomKeyRequestBody,
callback: (req: OutgoingRoomKeyRequest | null) => void,
): void {
const store = txn.objectStore("outgoingRoomKeyRequests");
const idx = store.index("session");
const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]);
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
// no match found
callback(null);
return;
}
const existing = cursor.value;
if (deepCompare(existing.requestBody, requestBody)) {
// got a match
callback(existing);
return;
}
// look at the next entry in the index
cursor.continue();
};
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states. If there are multiple
* requests in those states, an arbitrary one is chosen.
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
if (wantedStates.length === 0) {
return Promise.resolve(null);
}
// this is a bit tortuous because we need to make sure we do the lookup
// in a single transaction, to avoid having a race with the insertion
// code.
// index into the wantedStates array
let stateIndex = 0;
let result: OutgoingRoomKeyRequest;
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (cursor) {
// got a match
result = cursor.value;
return;
}
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => result);
}
/**
*
* @returns All elements in a given state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const index = store.index("state");
const request = index.getAll(wantedState);
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
}
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
let stateIndex = 0;
const results: OutgoingRoomKeyRequest[] = [];
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (cursor) {
const keyReq = cursor.value;
if (
keyReq.recipients.some(
(recipient: IRoomKeyRequestRecipient) =>
recipient.userId === userId && recipient.deviceId === deviceId,
)
) {
results.push(keyReq);
}
cursor.continue();
} else {
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => results);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
let result: OutgoingRoomKeyRequest | null = null;
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (!cursor) {
return;
}
const data = cursor.value;
if (data.state != expectedState) {
logger.warn(
`Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${data.state}`,
);
return;
}
Object.assign(data, updates);
cursor.update(data);
result = data;
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => result);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
return;
}
const data = cursor.value;
if (data.state != expectedState) {
logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`);
return;
}
cursor.delete();
};
return promiseifyTxn<OutgoingRoomKeyRequest | null>(txn);
}
// Olm Account
public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
@ -447,11 +150,6 @@ export class Backend implements CryptoStore {
};
}
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, CrossSigningKeyInfo>): void {
const objectStore = txn.objectStore("account");
objectStore.put(keys, "crossSigningKeys");
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: IDBTransaction,
type: K,
@ -526,24 +224,6 @@ export class Backend implements CryptoStore {
};
}
public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
const objectStore = txn.objectStore("sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
func(cursor.value);
cursor.continue();
} else {
func(null);
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
}
public storeEndToEndSession(
deviceKey: string,
sessionId: string,
@ -559,76 +239,6 @@ export class Backend implements CryptoStore {
});
}
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const txn = this.db.transaction("session_problems", "readwrite");
const objectStore = txn.objectStore("session_problems");
objectStore.put({
deviceKey,
type,
fixed,
time: Date.now(),
});
await promiseifyTxn(txn);
}
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
let result: IProblem | null = null;
const txn = this.db.transaction("session_problems", "readwrite");
const objectStore = txn.objectStore("session_problems");
const index = objectStore.index("deviceKey");
const req = index.getAll(deviceKey);
req.onsuccess = (): void => {
const problems = req.result;
if (!problems.length) {
result = null;
return;
}
problems.sort((a, b) => {
return a.time - b.time;
});
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
result = Object.assign({}, problem, { fixed: lastProblem.fixed });
return;
}
}
if (lastProblem.fixed) {
result = null;
} else {
result = lastProblem;
}
};
await promiseifyTxn(txn);
return result;
}
// FIXME: we should probably prune this when devices get deleted
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const txn = this.db.transaction("notified_error_devices", "readwrite");
const objectStore = txn.objectStore("notified_error_devices");
const ret: IOlmDevice[] = [];
await Promise.all(
devices.map((device) => {
return new Promise<void>((resolve) => {
const { userId, deviceInfo } = device;
const getReq = objectStore.get([userId, deviceInfo.deviceId]);
getReq.onsuccess = function (): void {
if (!getReq.result) {
objectStore.put({ userId, deviceId: deviceInfo.deviceId });
ret.push(device);
}
resolve();
};
});
}),
);
return ret;
}
/**
* Fetch a batch of Olm sessions from the database.
*
@ -730,57 +340,6 @@ export class Backend implements CryptoStore {
};
}
public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
const objectStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
const cursor = getReq.result;
if (cursor) {
try {
func({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
});
} catch (e) {
abortWithException(txn, <Error>e);
}
cursor.continue();
} else {
try {
func(null);
} catch (e) {
abortWithException(txn, <Error>e);
}
}
};
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: IDBTransaction,
): void {
const objectStore = txn.objectStore("inbound_group_sessions");
const addReq = objectStore.add({
senderCurve25519Key,
sessionId,
session: sessionData,
});
addReq.onerror = (ev): void => {
if (addReq.error?.name === "ConstraintError") {
// This stops the error from triggering the txn's onerror
ev.stopPropagation();
// ...and this stops it from aborting the transaction
ev.preventDefault();
logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId);
} else {
abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error?.name));
}
};
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@ -795,20 +354,6 @@ export class Backend implements CryptoStore {
});
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: IDBTransaction,
): void {
const objectStore = txn.objectStore("inbound_group_sessions_withheld");
objectStore.put({
senderCurve25519Key,
sessionId,
session: sessionData,
});
}
/**
* Count the number of Megolm sessions in the database.
*
@ -912,16 +457,6 @@ export class Backend implements CryptoStore {
};
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
const objectStore = txn.objectStore("device_data");
objectStore.put(deviceData, "-");
}
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
const objectStore = txn.objectStore("rooms");
objectStore.put(roomInfo, roomId);
}
public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {};
const objectStore = txn.objectStore("rooms");
@ -941,67 +476,6 @@ export class Backend implements CryptoStore {
};
}
// session backups
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
return new Promise((resolve, reject) => {
const sessions: ISession[] = [];
const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly");
txn.onerror = reject;
txn.oncomplete = function (): void {
resolve(sessions);
};
const objectStore = txn.objectStore("sessions_needing_backup");
const sessionStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
const cursor = getReq.result;
if (cursor) {
const sessionGetReq = sessionStore.get(cursor.key);
sessionGetReq.onsuccess = function (): void {
sessions.push({
senderKey: sessionGetReq.result.senderCurve25519Key,
sessionId: sessionGetReq.result.sessionId,
sessionData: sessionGetReq.result.session,
});
};
if (!limit || sessions.length < limit) {
cursor.continue();
}
}
};
});
}
public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readonly");
}
const objectStore = txn.objectStore("sessions_needing_backup");
return new Promise((resolve, reject) => {
const req = objectStore.count();
req.onerror = reject;
req.onsuccess = (): void => resolve(req.result);
});
}
public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readwrite");
}
const objectStore = txn.objectStore("sessions_needing_backup");
await Promise.all(
sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.delete([session.senderKey, session.sessionId]);
req.onsuccess = resolve;
req.onerror = reject;
});
}),
);
}
public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readwrite");
@ -1021,75 +495,6 @@ export class Backend implements CryptoStore {
);
}
public addSharedHistoryInboundGroupSession(
roomId: string,
senderKey: string,
sessionId: string,
txn?: IDBTransaction,
): void {
if (!txn) {
txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite");
}
const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
const req = objectStore.get([roomId]);
req.onsuccess = (): void => {
const { sessions } = req.result || { sessions: [] };
sessions.push([senderKey, sessionId]);
objectStore.put({ roomId, sessions });
};
}
public getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: IDBTransaction,
): Promise<[senderKey: string, sessionId: string][]> {
if (!txn) {
txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly");
}
const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
const req = objectStore.get([roomId]);
return new Promise((resolve, reject) => {
req.onsuccess = (): void => {
const { sessions } = req.result || { sessions: [] };
resolve(sessions);
};
req.onerror = reject;
});
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
if (!txn) {
txn = this.db.transaction("parked_shared_history", "readwrite");
}
const objectStore = txn.objectStore("parked_shared_history");
const req = objectStore.get([roomId]);
req.onsuccess = (): void => {
const { parked } = req.result || { parked: [] };
parked.push(parkedData);
objectStore.put({ roomId, parked });
};
}
public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
if (!txn) {
txn = this.db.transaction("parked_shared_history", "readwrite");
}
const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId);
return new Promise((resolve, reject) => {
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
resolve([]);
return;
}
const data = cursor.value;
cursor.delete();
resolve(data);
};
cursorReq.onerror = reject;
});
}
public doTxn<T>(
mode: Mode,
stores: string | string[],

View File

@ -22,23 +22,17 @@ import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors.t
import * as IndexedDBHelpers from "../../indexeddb-helpers.ts";
import {
type CryptoStore,
type IDeviceData,
type IProblem,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type OutgoingRoomKeyRequest,
type ParkedSharedHistory,
type SecretStorePrivateKeys,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type IRoomKeyRequestBody } from "../index.ts";
import { type IOlmDevice } from "../algorithms/megolm.ts";
import { type IRoomEncryption } from "../RoomList.ts";
import { type InboundGroupSessionData } from "../OlmDevice.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
/*
@ -282,110 +276,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend!.setMigrationState(migrationState);
}
/**
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
return this.backend!.getOrAddOutgoingRoomKeyRequest(request);
}
/**
* Look for an existing room key request
*
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.getOutgoingRoomKeyRequest(requestBody);
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states. If there are multiple
* requests in those states, an arbitrary one is chosen.
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.getOutgoingRoomKeyRequestByState(wantedStates);
}
/**
* Look for room key requests by state
* unlike above, return a list of all entries in one state.
*
* @returns Returns an array of requests in the given state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState);
}
/**
* Look for room key requests by target device and state
*
* @param userId - Target user ID
* @param deviceId - Target device ID
* @param wantedStates - list of acceptable states
*
* @returns resolves to a list of all the
* {@link OutgoingRoomKeyRequest}
*/
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.deleteOutgoingRoomKeyRequest(requestId, expectedState);
}
// Olm Account
/*
@ -438,16 +328,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getSecretStorePrivateKey(txn, func, type);
}
/**
* Write the cross-signing keys back to the store
*
* @param txn - An active transaction. See doTxn().
* @param keys - keys object as getCrossSigningKeys()
*/
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, CrossSigningKeyInfo>): void {
this.backend!.storeCrossSigningKeys(txn, keys);
}
/**
* Write the cross-signing private keys back to the store
*
@ -514,17 +394,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndSessions(deviceKey, txn, func);
}
/**
* Retrieve all end-to-end sessions
* @param txn - An active transaction. See doTxn().
* @param func - Called one for each session with
* an object with, deviceKey, lastReceivedMessageTs, sessionId
* and session keys.
*/
public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
this.backend!.getAllEndToEndSessions(txn, func);
}
/**
* Store a session between the logged-in user and another device
* @param deviceKey - The public key of the other device.
@ -541,18 +410,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn);
}
public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed);
}
public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp);
}
public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
return this.backend!.filterOutNotifiedErrorDevices(devices);
}
/**
* Count the number of Megolm sessions in the database.
*
@ -606,35 +463,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func);
}
/**
* Fetches all inbound group sessions in the store
* @param txn - An active transaction. See doTxn().
* @param func - Called once for each group session
* in the store with an object having keys `{senderKey, sessionId, sessionData}`,
* then once with null to indicate the end of the list.
*/
public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
this.backend!.getAllEndToEndInboundGroupSessions(txn, func);
}
/**
* Adds an end-to-end inbound group session to the store.
* If there already exists an inbound group session with the same
* senderCurve25519Key and sessionID, the session will not be added.
* @param senderCurve25519Key - The sender's curve 25519 key
* @param sessionId - The ID of the session
* @param sessionData - The session data structure
* @param txn - An active transaction. See doTxn().
*/
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: IDBTransaction,
): void {
this.backend!.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
/**
* Writes an end-to-end inbound group session to the store.
* If there already exists an inbound group session with the same
@ -653,15 +481,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: IDBTransaction,
): void {
this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn);
}
/**
* Fetch a batch of Megolm sessions from the database.
*
@ -686,44 +505,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions);
}
// End-to-end device tracking
/**
* Store the state of all tracked devices
* This contains devices for each user, a tracking state for each user
* and a sync token matching the point in time the snapshot represents.
* These all need to be written out in full each time such that the snapshot
* is always consistent, so they are stored in one object.
*
* @param txn - An active transaction. See doTxn().
*/
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
this.backend!.storeEndToEndDeviceData(deviceData, txn);
}
/**
* Get the state of all tracked devices
*
* @param txn - An active transaction. See doTxn().
* @param func - Function called with the
* device data
*/
public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
this.backend!.getEndToEndDeviceData(txn, func);
}
// End to End Rooms
/**
* Store the end-to-end state for a room.
* @param roomId - The room's ID.
* @param roomInfo - The end-to-end info for the room.
* @param txn - An active transaction. See doTxn().
*/
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
this.backend!.storeEndToEndRoom(roomId, roomInfo, txn);
}
/**
* Get an object of `roomId->roomInfo` for all e2e rooms in the store
* @param txn - An active transaction. See doTxn().
@ -733,37 +514,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndRooms(txn, func);
}
// session backups
/**
* Get the inbound group sessions that need to be backed up.
* @param limit - The maximum number of sessions to retrieve. 0
* for no limit.
* @returns resolves to an array of inbound group sessions
*/
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
return this.backend!.getSessionsNeedingBackup(limit);
}
/**
* Count the inbound group sessions that need to be backed up.
* @param txn - An active transaction. See doTxn(). (optional)
* @returns resolves to the number of sessions
*/
public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
return this.backend!.countSessionsNeedingBackup(txn);
}
/**
* Unmark sessions as needing to be backed up.
* @param sessions - The sessions that need to be backed up.
* @param txn - An active transaction. See doTxn(). (optional)
* @returns resolves when the sessions are unmarked
*/
public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
return this.backend!.unmarkSessionsNeedingBackup(sessions, txn);
}
/**
* Mark sessions as needing to be backed up.
* @param sessions - The sessions that need to be backed up.
@ -774,49 +524,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend!.markSessionsNeedingBackup(sessions, txn);
}
/**
* Add a shared-history group session for a room.
* @param roomId - The room that the key belongs to
* @param senderKey - The sender's curve 25519 key
* @param sessionId - The ID of the session
* @param txn - An active transaction. See doTxn(). (optional)
*/
public addSharedHistoryInboundGroupSession(
roomId: string,
senderKey: string,
sessionId: string,
txn?: IDBTransaction,
): void {
this.backend!.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn);
}
/**
* Get the shared-history group session for a room.
* @param roomId - The room that the key belongs to
* @param txn - An active transaction. See doTxn(). (optional)
* @returns Promise which resolves to an array of [senderKey, sessionId]
*/
public getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: IDBTransaction,
): Promise<[senderKey: string, sessionId: string][]> {
return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn);
}
/**
* Park a shared-history group session for a room we may be invited to later.
*/
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
this.backend!.addParkedSharedHistory(roomId, parkedData, txn);
}
/**
* Pop out all shared-history group sessions for a room.
*/
public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
return this.backend!.takeParkedSharedHistory(roomId, txn);
}
/**
* Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may

View File

@ -18,8 +18,6 @@ import { logger } from "../../logger.ts";
import { MemoryCryptoStore } from "./memory-crypto-store.ts";
import {
type CryptoStore,
type IDeviceData,
type IProblem,
type ISession,
type SessionExtended,
type ISessionInfo,
@ -28,11 +26,9 @@ import {
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type IOlmDevice } from "../algorithms/megolm.ts";
import { type IRoomEncryption } from "../RoomList.ts";
import { type InboundGroupSessionData } from "../OlmDevice.ts";
import { safeSet } from "../../utils.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
/**
@ -47,8 +43,6 @@ const E2E_PREFIX = "crypto.";
const KEY_END_TO_END_MIGRATION_STATE = E2E_PREFIX + "migration";
const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys";
const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices";
const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/";
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
@ -58,10 +52,6 @@ function keyEndToEndSessions(deviceKey: string): string {
return E2E_PREFIX + "sessions/" + deviceKey;
}
function keyEndToEndSessionProblems(deviceKey: string): string {
return E2E_PREFIX + "session.problems/" + deviceKey;
}
function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string {
return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
}
@ -173,75 +163,12 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(this._getEndToEndSessions(deviceKey) ?? {});
}
public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
const deviceKey = this.store.key(i)!.split("/")[1];
for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) {
func(sess);
}
}
}
}
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
const sessions = this._getEndToEndSessions(deviceKey) || {};
sessions[sessionId] = sessionInfo;
setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions);
}
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const key = keyEndToEndSessionProblems(deviceKey);
const problems = getJsonItem<IProblem[]>(this.store, key) || [];
problems.push({ type, fixed, time: Date.now() });
problems.sort((a, b) => {
return a.time - b.time;
});
setJsonItem(this.store, key, problems);
}
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
const key = keyEndToEndSessionProblems(deviceKey);
const problems = getJsonItem<IProblem[]>(this.store, key) || [];
if (!problems.length) {
return null;
}
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
return Object.assign({}, problem, { fixed: lastProblem.fixed });
}
}
if (lastProblem.fixed) {
return null;
} else {
return lastProblem;
}
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const notifiedErrorDevices =
getJsonItem<MemoryCryptoStore["notifiedErrorDevices"]>(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {};
const ret: IOlmDevice[] = [];
for (const device of devices) {
const { userId, deviceInfo } = device;
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices);
return ret;
}
/**
* Fetch a batch of Olm sessions from the database.
*
@ -306,37 +233,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
func({
senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
sessionData: getJsonItem(this.store, key)!,
});
}
}
func(null);
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId));
if (!existing) {
this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@ -346,15 +242,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData);
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void {
setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
}
/**
* Count the number of Megolm sessions in the database.
*
@ -431,18 +318,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
}
}
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(getJsonItem(this.store, KEY_DEVICE_DATA));
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
setJsonItem(this.store, KEY_DEVICE_DATA, deviceData);
}
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo);
}
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
const result: Record<string, IRoomEncryption> = {};
const prefix = keyEndToEndRoomsPrefix("");
@ -457,47 +332,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(result);
}
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const sessions: ISession[] = [];
for (const session in sessionsNeedingBackup) {
if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
// see getAllEndToEndInboundGroupSessions for the magic number explanations
const senderKey = session.slice(0, 43);
const sessionId = session.slice(44);
this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => {
sessions.push({
senderKey: senderKey,
sessionId: sessionId,
sessionData: sessionData!,
});
});
if (limit && sessions.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
public countSessionsNeedingBackup(): Promise<number> {
const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
return Promise.resolve(Object.keys(sessionsNeedingBackup).length);
}
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
const sessionsNeedingBackup =
getJsonItem<{
[senderKeySessionId: string]: string;
}>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
for (const session of sessions) {
delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId];
}
setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
return Promise.resolve();
}
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
const sessionsNeedingBackup =
getJsonItem<{
@ -545,10 +379,6 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(key);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void {
setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,

View File

@ -14,27 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../logger.ts";
import { deepCompare, promiseTry, safeSet } from "../../utils.ts";
import { safeSet } from "../../utils.ts";
import {
type CryptoStore,
type IDeviceData,
type IProblem,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type OutgoingRoomKeyRequest,
type ParkedSharedHistory,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type IRoomKeyRequestBody } from "../index.ts";
import { type IOlmDevice } from "../algorithms/megolm.ts";
import { type IRoomEncryption } from "../RoomList.ts";
import { type InboundGroupSessionData } from "../OlmDevice.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string {
@ -54,22 +47,16 @@ function decodeSessionKey(key: string): { senderKey: string; sessionId: string }
export class MemoryCryptoStore implements CryptoStore {
private migrationState: MigrationState = MigrationState.NOT_STARTED;
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
private account: string | null = null;
private crossSigningKeys: Record<string, CrossSigningKeyInfo> | null = null;
private privateKeys: Partial<SecretStorePrivateKeys> = {};
private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
private sessionProblems: { [deviceKey: string]: IProblem[] } = {};
private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {};
private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {};
private inboundGroupSessionsWithheld: Record<string, IWithheld> = {};
// Opaque device data object
private deviceData: IDeviceData | null = null;
private rooms: { [roomId: string]: IRoomEncryption } = {};
private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {};
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
@ -126,189 +113,6 @@ export class MemoryCryptoStore implements CryptoStore {
this.migrationState = migrationState;
}
/**
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return promiseTry(() => {
// first see if we already have an entry for this request.
const existing = this._getOutgoingRoomKeyRequest(requestBody);
if (existing) {
// this entry matches the request - return it.
logger.log(
`already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`,
);
return existing;
}
// we got to the end of the list without finding a match
// - add the new request.
logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
this.outgoingRoomKeyRequests.push(request);
return request;
});
}
/**
* Look for an existing room key request
*
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody));
}
/**
* Looks for existing room key request, and returns the result synchronously.
*
* @internal
*
* @param requestBody - existing request to look for
*
* @returns
* the matching request, or null if not found
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
for (const existing of this.outgoingRoomKeyRequests) {
if (deepCompare(existing.requestBody, requestBody)) {
return existing;
}
}
return null;
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (req.state === state) {
return Promise.resolve(req);
}
}
}
return Promise.resolve(null);
}
/**
*
* @returns All OutgoingRoomKeyRequests in state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState));
}
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
const results: OutgoingRoomKeyRequest[] = [];
for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (
req.state === state &&
req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId)
) {
results.push(req);
}
}
}
return Promise.resolve(results);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
for (const req of this.outgoingRoomKeyRequests) {
if (req.requestId !== requestId) {
continue;
}
if (req.state !== expectedState) {
logger.warn(
`Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${req.state}`,
);
return Promise.resolve(null);
}
Object.assign(req, updates);
return Promise.resolve(req);
}
return Promise.resolve(null);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) {
const req = this.outgoingRoomKeyRequests[i];
if (req.requestId !== requestId) {
continue;
}
if (req.state != expectedState) {
logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`);
return Promise.resolve(null);
}
this.outgoingRoomKeyRequests.splice(i, 1);
return Promise.resolve(req);
}
return Promise.resolve(null);
}
// Olm Account
public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void {
@ -332,10 +136,6 @@ export class MemoryCryptoStore implements CryptoStore {
func(result || null);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void {
this.crossSigningKeys = keys;
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@ -372,18 +172,6 @@ export class MemoryCryptoStore implements CryptoStore {
func(this.sessions[deviceKey] || {});
}
public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => {
Object.entries(deviceSessions).forEach(([sessionId, session]) => {
func({
...session,
deviceKey,
sessionId,
});
});
});
}
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
let deviceSessions = this.sessions[deviceKey];
if (deviceSessions === undefined) {
@ -393,52 +181,6 @@ export class MemoryCryptoStore implements CryptoStore {
safeSet(deviceSessions, sessionId, sessionInfo);
}
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []);
problems.push({ type, fixed, time: Date.now() });
problems.sort((a, b) => {
return a.time - b.time;
});
}
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
const problems = this.sessionProblems[deviceKey] || [];
if (!problems.length) {
return null;
}
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
return Object.assign({}, problem, { fixed: lastProblem.fixed });
}
}
if (lastProblem.fixed) {
return null;
} else {
return lastProblem;
}
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const notifiedErrorDevices = this.notifiedErrorDevices;
const ret: IOlmDevice[] = [];
for (const device of devices) {
const { userId, deviceInfo } = device;
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
return ret;
}
/**
* Fetch a batch of Olm sessions from the database.
*
@ -496,28 +238,6 @@ export class MemoryCryptoStore implements CryptoStore {
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (const key of Object.keys(this.inboundGroupSessions)) {
func({
...decodeSessionKey(key),
sessionData: this.inboundGroupSessions[key],
});
}
func(null);
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = encodeSessionKey(senderCurve25519Key, sessionId);
if (this.inboundGroupSessions[k] === undefined) {
this.inboundGroupSessions[k] = sessionData;
}
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@ -528,16 +248,6 @@ export class MemoryCryptoStore implements CryptoStore {
this.inboundGroupSessions[k] = sessionData;
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void {
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessionsWithheld[k] = sessionData;
}
/**
* Count the number of Megolm sessions in the database.
*
@ -594,54 +304,12 @@ export class MemoryCryptoStore implements CryptoStore {
}
}
// Device Data
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(this.deviceData);
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
this.deviceData = deviceData;
}
// E2E rooms
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
this.rooms[roomId] = roomInfo;
}
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
func(this.rooms);
}
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
const sessions: ISession[] = [];
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
...decodeSessionKey(session),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
public countSessionsNeedingBackup(): Promise<number> {
return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length);
}
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
delete this.sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
}
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
@ -650,28 +318,6 @@ export class MemoryCryptoStore implements CryptoStore {
return Promise.resolve();
}
public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void {
const sessions = this.sharedHistoryInboundGroupSessions[roomId] || [];
sessions.push([senderKey, sessionId]);
this.sharedHistoryInboundGroupSessions[roomId] = sessions;
}
public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> {
return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
parked.push(parkedData);
this.parkedSharedHistory.set(roomId, parked);
}
public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
this.parkedSharedHistory.delete(roomId);
return Promise.resolve(parked);
}
// Session key backups
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {

View File

@ -1,409 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 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.
*/
/**
* Base class for verification methods.
*/
import { MatrixEvent } from "../../models/event.ts";
import { EventType } from "../../@types/event.ts";
import { logger } from "../../logger.ts";
import { DeviceInfo } from "../deviceinfo.ts";
import { newTimeoutError } from "./Error.ts";
import { type KeysDuringVerification, requestKeysDuringVerification } from "../CrossSigning.ts";
import { type IVerificationChannel } from "./request/Channel.ts";
import { type MatrixClient } from "../../client.ts";
import { type VerificationRequest } from "./request/VerificationRequest.ts";
import { TypedEventEmitter } from "../../models/typed-event-emitter.ts";
import {
type ShowQrCodeCallbacks,
type ShowSasCallbacks,
type Verifier,
VerifierEvent,
type VerifierEventHandlerMap,
} from "../../crypto-api/verification.ts";
const timeoutException = new Error("Verification timed out");
export class SwitchStartEventError extends Error {
public constructor(public readonly startEvent: MatrixEvent | null) {
super();
}
}
export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void;
/** @deprecated use VerifierEvent */
export type VerificationEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const VerificationEvent = VerifierEvent;
/** @deprecated use VerifierEventHandlerMap */
export type VerificationEventHandlerMap = {
[VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void;
};
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain
// backwards compatibility with applications that reference the class.
export class VerificationBase<
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Events extends string = VerifierEvent,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Arguments = VerifierEventHandlerMap,
>
extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap>
implements Verifier
{
private cancelled = false;
private _done = false;
private promise: Promise<void> | null = null;
private transactionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
protected expectedEvent?: string;
private resolve?: () => void;
private reject?: (e: Error | MatrixEvent) => void;
private resolveEvent?: (e: MatrixEvent) => void;
private rejectEvent?: (e: Error) => void;
private started?: boolean;
/**
* Base class for verification methods.
*
* <p>Once a verifier object is created, the verification can be started by
* calling the verify() method, which will return a promise that will
* resolve when the verification is completed, or reject if it could not
* complete.</p>
*
* <p>Subclasses must have a NAME class property.</p>
*
* @param channel - the verification channel to send verification messages over.
* TODO: Channel types
*
* @param baseApis - base matrix api interface
*
* @param userId - the user ID that is being verified
*
* @param deviceId - the device ID that is being verified
*
* @param startEvent - the m.key.verification.start event that
* initiated this verification, if any
*
* @param request - the key verification request object related to
* this verification, if any
*/
public constructor(
public readonly channel: IVerificationChannel,
public readonly baseApis: MatrixClient,
public readonly userId: string,
public readonly deviceId: string,
public startEvent: MatrixEvent | null,
public readonly request: VerificationRequest,
) {
super();
}
public get initiatedByMe(): boolean {
// if there is no start event yet,
// we probably want to send it,
// which happens if we initiate
if (!this.startEvent) {
return true;
}
const sender = this.startEvent.getSender();
const content = this.startEvent.getContent();
return sender === this.baseApis.getUserId() && content.from_device === this.baseApis.getDeviceId();
}
public get hasBeenCancelled(): boolean {
return this.cancelled;
}
private resetTimer(): void {
logger.info("Refreshing/starting the verification transaction timeout timer");
if (this.transactionTimeoutTimer !== null) {
clearTimeout(this.transactionTimeoutTimer);
}
this.transactionTimeoutTimer = setTimeout(
() => {
if (!this._done && !this.cancelled) {
logger.info("Triggering verification timeout");
this.cancel(timeoutException);
}
},
10 * 60 * 1000,
); // 10 minutes
}
private endTimer(): void {
if (this.transactionTimeoutTimer !== null) {
clearTimeout(this.transactionTimeoutTimer);
this.transactionTimeoutTimer = null;
}
}
protected send(type: string, uncompletedContent: Record<string, any>): Promise<void> {
return this.channel.send(type, uncompletedContent);
}
protected waitForEvent(type: string): Promise<MatrixEvent> {
if (this._done) {
return Promise.reject(new Error("Verification is already done"));
}
const existingEvent = this.request.getEventFromOtherParty(type);
if (existingEvent) {
return Promise.resolve(existingEvent);
}
this.expectedEvent = type;
return new Promise((resolve, reject) => {
this.resolveEvent = resolve;
this.rejectEvent = reject;
});
}
public canSwitchStartEvent(event: MatrixEvent): boolean {
return false;
}
public switchStartEvent(event: MatrixEvent): void {
if (this.canSwitchStartEvent(event)) {
logger.log("Verification Base: switching verification start event", { restartingFlow: !!this.rejectEvent });
if (this.rejectEvent) {
const reject = this.rejectEvent;
this.rejectEvent = undefined;
reject(new SwitchStartEventError(event));
} else {
this.startEvent = event;
}
}
}
public handleEvent(e: MatrixEvent): void {
if (this._done) {
return;
} else if (e.getType() === this.expectedEvent) {
// if we receive an expected m.key.verification.done, then just
// ignore it, since we don't need to do anything about it
if (this.expectedEvent !== EventType.KeyVerificationDone) {
this.expectedEvent = undefined;
this.rejectEvent = undefined;
this.resetTimer();
this.resolveEvent?.(e);
}
} else if (e.getType() === EventType.KeyVerificationCancel) {
const reject = this.reject;
this.reject = undefined;
// there is only promise to reject if verify has been called
if (reject) {
const content = e.getContent();
const { reason, code } = content;
reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`));
}
} else if (this.expectedEvent) {
// only cancel if there is an event expected.
// if there is no event expected, it means verify() wasn't called
// and we're just replaying the timeline events when syncing
// after a refresh when the events haven't been stored in the cache yet.
const exception = new Error(
"Unexpected message: expecting " + this.expectedEvent + " but got " + e.getType(),
);
this.expectedEvent = undefined;
if (this.rejectEvent) {
const reject = this.rejectEvent;
this.rejectEvent = undefined;
reject(exception);
}
this.cancel(exception);
}
}
public async done(): Promise<KeysDuringVerification | void> {
this.endTimer(); // always kill the activity timer
if (!this._done) {
this.request.onVerifierFinished();
this.resolve?.();
return requestKeysDuringVerification(this.baseApis, this.userId, this.deviceId);
}
}
public cancel(e: Error | MatrixEvent): void {
this.endTimer(); // always kill the activity timer
if (!this._done) {
this.cancelled = true;
this.request.onVerifierCancelled();
if (this.userId && this.deviceId) {
// send a cancellation to the other user (if it wasn't
// cancelled by the other user)
if (e === timeoutException) {
const timeoutEvent = newTimeoutError();
this.send(timeoutEvent.getType(), timeoutEvent.getContent());
} else if (e instanceof MatrixEvent) {
const sender = e.getSender();
if (sender !== this.userId) {
const content = e.getContent();
if (e.getType() === EventType.KeyVerificationCancel) {
content.code = content.code || "m.unknown";
content.reason = content.reason || content.body || "Unknown reason";
this.send(EventType.KeyVerificationCancel, content);
} else {
this.send(EventType.KeyVerificationCancel, {
code: "m.unknown",
reason: content.body || "Unknown reason",
});
}
}
} else {
this.send(EventType.KeyVerificationCancel, {
code: "m.unknown",
reason: e.toString(),
});
}
}
if (this.promise !== null) {
// when we cancel without a promise, we end up with a promise
// but no reject function. If cancel is called again, we'd error.
if (this.reject) this.reject(e);
} else {
// FIXME: this causes an "Uncaught promise" console message
// if nothing ends up chaining this promise.
this.promise = Promise.reject(e);
}
// Also emit a 'cancel' event that the app can listen for to detect cancellation
// before calling verify()
this.emit(VerificationEvent.Cancel, e);
}
}
/**
* Begin the key verification
*
* @returns Promise which resolves when the verification has
* completed.
*/
public verify(): Promise<void> {
if (this.promise) return this.promise;
this.promise = new Promise((resolve, reject) => {
this.resolve = (...args): void => {
this._done = true;
this.endTimer();
resolve(...args);
};
this.reject = (e: Error | MatrixEvent): void => {
this._done = true;
this.endTimer();
reject(e);
};
});
if (this.doVerification && !this.started) {
this.started = true;
this.resetTimer(); // restart the timeout
new Promise<void>((resolve, reject) => {
const crossSignId = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(this.userId)?.getId();
if (crossSignId === this.deviceId) {
reject(new Error("Device ID is the same as the cross-signing ID"));
}
resolve();
})
.then(() => this.doVerification!())
.then(this.done.bind(this), this.cancel.bind(this));
}
return this.promise;
}
protected doVerification?: () => Promise<void>;
protected async verifyKeys(userId: string, keys: Record<string, string>, verifier: KeyVerifier): Promise<void> {
// we try to verify all the keys that we're told about, but we might
// not know about all of them, so keep track of the keys that we know
// about, and ignore the rest
const verifiedDevices: [string, string, string][] = [];
for (const [keyId, keyInfo] of Object.entries(keys)) {
const deviceId = keyId.split(":", 2)[1];
const device = this.baseApis.getStoredDevice(userId, deviceId);
if (device) {
verifier(keyId, device, keyInfo);
verifiedDevices.push([deviceId, keyId, device.keys[keyId]]);
} else {
const crossSigningInfo = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(userId);
if (crossSigningInfo && crossSigningInfo.getId() === deviceId) {
verifier(
keyId,
DeviceInfo.fromStorage(
{
keys: {
[keyId]: deviceId,
},
},
deviceId,
),
keyInfo,
);
verifiedDevices.push([deviceId, keyId, deviceId]);
} else {
logger.warn(`verification: Could not find device ${deviceId} to verify`);
}
}
}
// if none of the keys could be verified, then error because the app
// should be informed about that
if (!verifiedDevices.length) {
throw new Error("No devices could be verified");
}
logger.info("Verification completed! Marking devices verified: ", verifiedDevices);
// TODO: There should probably be a batch version of this, otherwise it's going
// to upload each signature in a separate API call which is silly because the
// API supports as many signatures as you like.
for (const [deviceId, keyId, key] of verifiedDevices) {
await this.baseApis.crypto!.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key });
}
// if one of the user's own devices is being marked as verified / unverified,
// check the key backup status, since whether or not we use this depends on
// whether it has a signature from a verified device
if (userId == this.baseApis.credentials.userId) {
await this.baseApis.checkKeyBackup();
}
}
public get events(): string[] | undefined {
return undefined;
}
/**
* Get the details for an SAS verification, if one is in progress
*
* Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm
* the SAS matches.
*/
public getShowSasCallbacks(): ShowSasCallbacks | null {
return null;
}
/**
* Get the details for reciprocating QR code verification, if one is in progress
*
* Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has
* already scanned our QR code), and we are waiting for the user to confirm.
*/
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
return null;
}
}

View File

@ -1,76 +0,0 @@
/*
Copyright 2018 - 2021 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.
*/
/**
* Error messages.
*/
import { MatrixEvent } from "../../models/event.ts";
import { EventType } from "../../@types/event.ts";
export function newVerificationError(code: string, reason: string, extraData?: Record<string, any>): MatrixEvent {
const content = Object.assign({}, { code, reason }, extraData);
return new MatrixEvent({
type: EventType.KeyVerificationCancel,
content,
});
}
export function errorFactory(code: string, reason: string): (extraData?: Record<string, any>) => MatrixEvent {
return function (extraData?: Record<string, any>) {
return newVerificationError(code, reason, extraData);
};
}
/**
* The verification was cancelled by the user.
*/
export const newUserCancelledError = errorFactory("m.user", "Cancelled by user");
/**
* The verification timed out.
*/
export const newTimeoutError = errorFactory("m.timeout", "Timed out");
/**
* An unknown method was selected.
*/
export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method");
/**
* An unexpected message was sent.
*/
export const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message");
/**
* The key does not match.
*/
export const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch");
/**
* An invalid message was sent.
*/
export const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message");
export function errorFromEvent(event: MatrixEvent): { code: string; reason: string } {
const content = event.getContent();
if (content) {
const { code, reason } = content;
return { code, reason };
} else {
return { code: "Unknown error", reason: "m.unknown" };
}
}

View File

@ -1,50 +0,0 @@
/*
Copyright 2020 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.
*/
/**
* Verification method that is illegal to have (cannot possibly
* do verification with this method).
*/
import { VerificationBase as Base, type VerificationEvent, type VerificationEventHandlerMap } from "./Base.ts";
import { type IVerificationChannel } from "./request/Channel.ts";
import { type MatrixClient } from "../../client.ts";
import { type MatrixEvent } from "../../models/event.ts";
import { type VerificationRequest } from "./request/VerificationRequest.ts";
export class IllegalMethod extends Base<VerificationEvent, VerificationEventHandlerMap> {
public static factory(
channel: IVerificationChannel,
baseApis: MatrixClient,
userId: string,
deviceId: string,
startEvent: MatrixEvent,
request: VerificationRequest,
): IllegalMethod {
return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
public static get NAME(): string {
// Typically the name will be something else, but to complete
// the contract we offer a default one here.
return "org.matrix.illegal_method";
}
protected doVerification = async (): Promise<void> => {
throw new Error("Verification is not possible with this method");
};
}

View File

@ -1,310 +0,0 @@
/*
Copyright 2018 - 2021 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.
*/
/**
* QR code key verification.
*/
import { VerificationBase as Base } from "./Base.ts";
import { newKeyMismatchError, newUserCancelledError } from "./Error.ts";
import { decodeBase64, encodeUnpaddedBase64 } from "../../base64.ts";
import { logger } from "../../logger.ts";
import { type VerificationRequest } from "./request/VerificationRequest.ts";
import { type MatrixClient } from "../../client.ts";
import { type IVerificationChannel } from "./request/Channel.ts";
import { type MatrixEvent } from "../../models/event.ts";
import { type ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification.ts";
import { VerificationMethod } from "../../types.ts";
export const SHOW_QR_CODE_METHOD = VerificationMethod.ShowQrCode;
export const SCAN_QR_CODE_METHOD = VerificationMethod.ScanQrCode;
/** @deprecated use VerifierEvent */
export type QrCodeEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const QrCodeEvent = VerifierEvent;
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
export class ReciprocateQRCode extends Base {
public reciprocateQREvent?: ShowQrCodeCallbacks;
public static factory(
channel: IVerificationChannel,
baseApis: MatrixClient,
userId: string,
deviceId: string,
startEvent: MatrixEvent,
request: VerificationRequest,
): ReciprocateQRCode {
return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
public static get NAME(): string {
return "m.reciprocate.v1";
}
protected doVerification = async (): Promise<void> => {
if (!this.startEvent) {
// TODO: Support scanning QR codes
throw new Error("It is not currently possible to start verification" + "with this method yet.");
}
const { qrCodeData } = this.request;
// 1. check the secret
if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) {
throw newKeyMismatchError();
}
// 2. ask if other user shows shield as well
await new Promise<void>((resolve, reject) => {
this.reciprocateQREvent = {
confirm: resolve,
cancel: (): void => reject(newUserCancelledError()),
};
this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent);
});
// 3. determine key to sign / mark as trusted
const keys: Record<string, string> = {};
switch (qrCodeData?.mode) {
case Mode.VerifyOtherUser: {
// add master key to keys to be signed, only if we're not doing self-verification
const masterKey = qrCodeData.otherUserMasterKey;
keys[`ed25519:${masterKey}`] = masterKey!;
break;
}
case Mode.VerifySelfTrusted: {
const deviceId = this.request.targetDevice.deviceId;
keys[`ed25519:${deviceId}`] = qrCodeData.otherDeviceKey!;
break;
}
case Mode.VerifySelfUntrusted: {
const masterKey = qrCodeData.myMasterKey;
keys[`ed25519:${masterKey}`] = masterKey!;
break;
}
}
// 4. sign the key (or mark own MSK as verified in case of MODE_VERIFY_SELF_TRUSTED)
await this.verifyKeys(this.userId, keys, (keyId, device, keyInfo) => {
// make sure the device has the expected keys
const targetKey = keys[keyId];
if (!targetKey) throw newKeyMismatchError();
if (keyInfo !== targetKey) {
logger.error("key ID from key info does not match");
throw newKeyMismatchError();
}
for (const deviceKeyId in device.keys) {
if (!deviceKeyId.startsWith("ed25519")) continue;
const deviceTargetKey = keys[deviceKeyId];
if (!deviceTargetKey) throw newKeyMismatchError();
if (device.keys[deviceKeyId] !== deviceTargetKey) {
logger.error("master key does not match");
throw newKeyMismatchError();
}
}
});
};
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
return this.reciprocateQREvent ?? null;
}
}
const CODE_VERSION = 0x02; // the version of binary QR codes we support
const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format
enum Mode {
VerifyOtherUser = 0x00, // Verifying someone who isn't us
VerifySelfTrusted = 0x01, // We trust the master key
VerifySelfUntrusted = 0x02, // We do not trust the master key
}
interface IQrData {
prefix: string;
version: number;
mode: Mode;
transactionId?: string;
firstKeyB64: string;
secondKeyB64: string;
secretB64: string;
}
export class QRCodeData {
public constructor(
public readonly mode: Mode,
private readonly sharedSecret: string,
// only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code
public readonly otherUserMasterKey: string | null,
// only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code
public readonly otherDeviceKey: string | null,
// only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code
public readonly myMasterKey: string | null,
private readonly buffer: Buffer,
) {}
public static async create(request: VerificationRequest, client: MatrixClient): Promise<QRCodeData> {
const sharedSecret = QRCodeData.generateSharedSecret();
const mode = QRCodeData.determineMode(request, client);
let otherUserMasterKey: string | null = null;
let otherDeviceKey: string | null = null;
let myMasterKey: string | null = null;
if (mode === Mode.VerifyOtherUser) {
const otherUserCrossSigningInfo = client.getStoredCrossSigningForUser(request.otherUserId);
otherUserMasterKey = otherUserCrossSigningInfo!.getId("master");
} else if (mode === Mode.VerifySelfTrusted) {
otherDeviceKey = await QRCodeData.getOtherDeviceKey(request, client);
} else if (mode === Mode.VerifySelfUntrusted) {
const myUserId = client.getUserId()!;
const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId);
myMasterKey = myCrossSigningInfo!.getId("master");
}
const qrData = QRCodeData.generateQrData(
request,
client,
mode,
sharedSecret,
otherUserMasterKey!,
otherDeviceKey!,
myMasterKey!,
);
const buffer = QRCodeData.generateBuffer(qrData);
return new QRCodeData(mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer);
}
/**
* The unpadded base64 encoded shared secret.
*/
public get encodedSharedSecret(): string {
return this.sharedSecret;
}
public getBuffer(): Buffer {
return this.buffer;
}
private static generateSharedSecret(): string {
const secretBytes = new Uint8Array(11);
globalThis.crypto.getRandomValues(secretBytes);
return encodeUnpaddedBase64(secretBytes);
}
private static async getOtherDeviceKey(request: VerificationRequest, client: MatrixClient): Promise<string> {
const myUserId = client.getUserId()!;
const otherDevice = request.targetDevice;
const device = otherDevice.deviceId ? client.getStoredDevice(myUserId, otherDevice.deviceId) : undefined;
if (!device) {
throw new Error("could not find device " + otherDevice?.deviceId);
}
return device.getFingerprint();
}
private static determineMode(request: VerificationRequest, client: MatrixClient): Mode {
const myUserId = client.getUserId();
const otherUserId = request.otherUserId;
let mode = Mode.VerifyOtherUser;
if (myUserId === otherUserId) {
// Mode changes depending on whether or not we trust the master cross signing key
const myTrust = client.checkUserTrust(myUserId);
if (myTrust.isCrossSigningVerified()) {
mode = Mode.VerifySelfTrusted;
} else {
mode = Mode.VerifySelfUntrusted;
}
}
return mode;
}
private static generateQrData(
request: VerificationRequest,
client: MatrixClient,
mode: Mode,
encodedSharedSecret: string,
otherUserMasterKey?: string,
otherDeviceKey?: string,
myMasterKey?: string,
): IQrData {
const myUserId = client.getUserId()!;
const transactionId = request.channel.transactionId;
const qrData: IQrData = {
prefix: BINARY_PREFIX,
version: CODE_VERSION,
mode,
transactionId,
firstKeyB64: "", // worked out shortly
secondKeyB64: "", // worked out shortly
secretB64: encodedSharedSecret,
};
const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId);
if (mode === Mode.VerifyOtherUser) {
// First key is our master cross signing key
qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!;
// Second key is the other user's master cross signing key
qrData.secondKeyB64 = otherUserMasterKey!;
} else if (mode === Mode.VerifySelfTrusted) {
// First key is our master cross signing key
qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!;
qrData.secondKeyB64 = otherDeviceKey!;
} else if (mode === Mode.VerifySelfUntrusted) {
// First key is our device's key
qrData.firstKeyB64 = client.getDeviceEd25519Key()!;
// Second key is what we think our master cross signing key is
qrData.secondKeyB64 = myMasterKey!;
}
return qrData;
}
private static generateBuffer(qrData: IQrData): Buffer {
let buf = Buffer.alloc(0); // we'll concat our way through life
const appendByte = (b: number): void => {
const tmpBuf = Buffer.from([b]);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendInt = (i: number): void => {
const tmpBuf = Buffer.alloc(2);
tmpBuf.writeInt16BE(i, 0);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendStr = (s: string, enc: BufferEncoding, withLengthPrefix = true): void => {
const tmpBuf = Buffer.from(s, enc);
if (withLengthPrefix) appendInt(tmpBuf.byteLength);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendEncBase64 = (b64: string): void => {
const b = decodeBase64(b64);
const tmpBuf = Buffer.from(b);
buf = Buffer.concat([buf, tmpBuf]);
};
// Actually build the buffer for the QR code
appendStr(qrData.prefix, "ascii", false);
appendByte(qrData.version);
appendByte(qrData.mode);
appendStr(qrData.transactionId!, "utf-8");
appendEncBase64(qrData.firstKeyB64);
appendEncBase64(qrData.secondKeyB64);
appendEncBase64(qrData.secretB64);
return buf;
}
}

View File

@ -1,499 +0,0 @@
/*
Copyright 2018 - 2021 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.
*/
/**
* Short Authentication String (SAS) verification.
*/
import anotherjson from "another-json";
import { type Utility, type SAS as OlmSAS } from "@matrix-org/olm";
import { VerificationBase as Base, SwitchStartEventError } from "./Base.ts";
import {
errorFactory,
newInvalidMessageError,
newKeyMismatchError,
newUnknownMethodError,
newUserCancelledError,
} from "./Error.ts";
import { logger } from "../../logger.ts";
import { type IContent, type MatrixEvent } from "../../models/event.ts";
import { generateDecimalSas } from "./SASDecimal.ts";
import { EventType } from "../../@types/event.ts";
import {
type EmojiMapping,
type GeneratedSas,
type ShowSasCallbacks,
VerifierEvent,
} from "../../crypto-api/verification.ts";
import { VerificationMethod } from "../../types.ts";
// backwards-compatibility exports
export type {
ShowSasCallbacks as ISasEvent,
GeneratedSas as IGeneratedSas,
EmojiMapping,
} from "../../crypto-api/verification.ts";
const START_TYPE = EventType.KeyVerificationStart;
const EVENTS = [EventType.KeyVerificationAccept, EventType.KeyVerificationKey, EventType.KeyVerificationMac];
let olmutil: Utility;
const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short authentication string");
const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
// This list was generated from the data in the Matrix specification [1] with the following command:
//
// jq -r '.[] | " [\"" + .emoji + "\", \"" + (.description|ascii_downcase) + "\"], // " + (.number|tostring)' sas-emoji.json
//
// [1]: https://github.com/matrix-org/matrix-spec/blob/main/data-definitions/sas-emoji.json
const emojiMapping: EmojiMapping[] = [
["🐶", "dog"], // 0
["🐱", "cat"], // 1
["🦁", "lion"], // 2
["🐎", "horse"], // 3
["🦄", "unicorn"], // 4
["🐷", "pig"], // 5
["🐘", "elephant"], // 6
["🐰", "rabbit"], // 7
["🐼", "panda"], // 8
["🐓", "rooster"], // 9
["🐧", "penguin"], // 10
["🐢", "turtle"], // 11
["🐟", "fish"], // 12
["🐙", "octopus"], // 13
["🦋", "butterfly"], // 14
["🌷", "flower"], // 15
["🌳", "tree"], // 16
["🌵", "cactus"], // 17
["🍄", "mushroom"], // 18
["🌏", "globe"], // 19
["🌙", "moon"], // 20
["☁️", "cloud"], // 21
["🔥", "fire"], // 22
["🍌", "banana"], // 23
["🍎", "apple"], // 24
["🍓", "strawberry"], // 25
["🌽", "corn"], // 26
["🍕", "pizza"], // 27
["🎂", "cake"], // 28
["❤️", "heart"], // 29
["😀", "smiley"], // 30
["🤖", "robot"], // 31
["🎩", "hat"], // 32
["👓", "glasses"], // 33
["🔧", "spanner"], // 34
["🎅", "santa"], // 35
["👍", "thumbs up"], // 36
["☂️", "umbrella"], // 37
["⌛", "hourglass"], // 38
["⏰", "clock"], // 39
["🎁", "gift"], // 40
["💡", "light bulb"], // 41
["📕", "book"], // 42
["✏️", "pencil"], // 43
["📎", "paperclip"], // 44
["✂️", "scissors"], // 45
["🔒", "lock"], // 46
["🔑", "key"], // 47
["🔨", "hammer"], // 48
["☎️", "telephone"], // 49
["🏁", "flag"], // 50
["🚂", "train"], // 51
["🚲", "bicycle"], // 52
["✈️", "aeroplane"], // 53
["🚀", "rocket"], // 54
["🏆", "trophy"], // 55
["⚽", "ball"], // 56
["🎸", "guitar"], // 57
["🎺", "trumpet"], // 58
["🔔", "bell"], // 59
["⚓", "anchor"], // 60
["🎧", "headphones"], // 61
["📁", "folder"], // 62
["📌", "pin"], // 63
];
function generateEmojiSas(sasBytes: number[]): EmojiMapping[] {
const emojis = [
// just like base64 encoding
sasBytes[0] >> 2,
((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4),
((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6),
sasBytes[2] & 0x3f,
sasBytes[3] >> 2,
((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4),
((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6),
];
return emojis.map((num) => emojiMapping[num]);
}
const sasGenerators = {
decimal: generateDecimalSas,
emoji: generateEmojiSas,
} as const;
function generateSas(sasBytes: Uint8Array, methods: string[]): GeneratedSas {
const sas: GeneratedSas = {};
for (const method of methods) {
if (method in sasGenerators) {
// @ts-ignore - ts doesn't like us mixing types like this
sas[method] = sasGenerators[method](Array.from(sasBytes));
}
}
return sas;
}
const macMethods = {
"hkdf-hmac-sha256": "calculate_mac",
"org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64",
"hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64",
"hmac-sha256": "calculate_mac_long_kdf",
} as const;
type MacMethod = keyof typeof macMethods;
function calculateMAC(olmSAS: OlmSAS, method: MacMethod) {
return function (input: string, info: string): string {
const mac = olmSAS[macMethods[method]](input, info);
logger.log("SAS calculateMAC:", method, [input, info], mac);
return mac;
};
}
const calculateKeyAgreement = {
// eslint-disable-next-line @typescript-eslint/naming-convention
"curve25519-hkdf-sha256": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` + `${sas.ourSASPubKey}|`;
const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`;
const sasInfo =
"MATRIX_KEY_VERIFICATION_SAS|" +
(sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) +
sas.channel.transactionId;
return olmSAS.generate_bytes(sasInfo, bytes);
},
"curve25519": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
const ourInfo = `${sas.baseApis.getUserId()}${sas.baseApis.deviceId}`;
const theirInfo = `${sas.userId}${sas.deviceId}`;
const sasInfo =
"MATRIX_KEY_VERIFICATION_SAS" +
(sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) +
sas.channel.transactionId;
return olmSAS.generate_bytes(sasInfo, bytes);
},
} as const;
type KeyAgreement = keyof typeof calculateKeyAgreement;
/* lists of algorithms/methods that are supported. The key agreement, hashes,
* and MAC lists should be sorted in order of preference (most preferred
* first).
*/
const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"];
const HASHES_LIST = ["sha256"];
const MAC_LIST: MacMethod[] = [
"hkdf-hmac-sha256.v2",
"org.matrix.msc3783.hkdf-hmac-sha256",
"hkdf-hmac-sha256",
"hmac-sha256",
];
const SAS_LIST = Object.keys(sasGenerators);
const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST);
const HASHES_SET = new Set(HASHES_LIST);
const MAC_SET = new Set(MAC_LIST);
const SAS_SET = new Set(SAS_LIST);
function intersection<T>(anArray: T[], aSet: Set<T>): T[] {
return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : [];
}
/** @deprecated use VerifierEvent */
export type SasEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const SasEvent = VerifierEvent;
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
export class SAS extends Base {
private waitingForAccept?: boolean;
public ourSASPubKey?: string;
public theirSASPubKey?: string;
public sasEvent?: ShowSasCallbacks;
// eslint-disable-next-line @typescript-eslint/naming-convention
public static get NAME(): string {
return VerificationMethod.Sas;
}
public get events(): string[] {
return EVENTS;
}
protected doVerification = async (): Promise<void> => {
await globalThis.Olm.init();
olmutil = olmutil || new globalThis.Olm.Utility();
// make sure user's keys are downloaded
await this.baseApis.downloadKeys([this.userId]);
let retry = false;
do {
try {
if (this.initiatedByMe) {
return await this.doSendVerification();
} else {
return await this.doRespondVerification();
}
} catch (err) {
if (err instanceof SwitchStartEventError) {
// this changes what initiatedByMe returns
this.startEvent = err.startEvent;
retry = true;
} else {
throw err;
}
}
} while (retry);
};
public canSwitchStartEvent(event: MatrixEvent): boolean {
if (event.getType() !== START_TYPE) {
return false;
}
const content = event.getContent();
return content?.method === SAS.NAME && !!this.waitingForAccept;
}
private async sendStart(): Promise<Record<string, any>> {
const startContent = this.channel.completeContent(START_TYPE, {
method: SAS.NAME,
from_device: this.baseApis.deviceId,
key_agreement_protocols: KEY_AGREEMENT_LIST,
hashes: HASHES_LIST,
message_authentication_codes: MAC_LIST,
// FIXME: allow app to specify what SAS methods can be used
short_authentication_string: SAS_LIST,
});
await this.channel.sendCompleted(START_TYPE, startContent);
return startContent;
}
private async verifyAndCheckMAC(
keyAgreement: KeyAgreement,
sasMethods: string[],
olmSAS: OlmSAS,
macMethod: MacMethod,
): Promise<void> {
const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6);
const verifySAS = new Promise<void>((resolve, reject) => {
this.sasEvent = {
sas: generateSas(sasBytes, sasMethods),
confirm: async (): Promise<void> => {
try {
await this.sendMAC(olmSAS, macMethod);
resolve();
} catch (err) {
reject(err);
}
},
cancel: (): void => reject(newUserCancelledError()),
mismatch: (): void => reject(newMismatchedSASError()),
};
this.emit(SasEvent.ShowSas, this.sasEvent);
});
const [e] = await Promise.all([
this.waitForEvent(EventType.KeyVerificationMac).then((e) => {
// we don't expect any more messages from the other
// party, and they may send a m.key.verification.done
// when they're done on their end
this.expectedEvent = EventType.KeyVerificationDone;
return e;
}),
verifySAS,
]);
const content = e.getContent();
await this.checkMAC(olmSAS, content, macMethod);
}
private async doSendVerification(): Promise<void> {
this.waitingForAccept = true;
let startContent;
if (this.startEvent) {
startContent = this.channel.completedContentFromEvent(this.startEvent);
} else {
startContent = await this.sendStart();
}
// we might have switched to a different start event,
// but was we didn't call _waitForEvent there was no
// call that could throw yet. So check manually that
// we're still on the initiator side
if (!this.initiatedByMe) {
throw new SwitchStartEventError(this.startEvent);
}
let e: MatrixEvent;
try {
e = await this.waitForEvent(EventType.KeyVerificationAccept);
} finally {
this.waitingForAccept = false;
}
let content = e.getContent();
const sasMethods = intersection(content.short_authentication_string, SAS_SET);
if (
!(
KEY_AGREEMENT_SET.has(content.key_agreement_protocol) &&
HASHES_SET.has(content.hash) &&
MAC_SET.has(content.message_authentication_code) &&
sasMethods.length
)
) {
throw newUnknownMethodError();
}
if (typeof content.commitment !== "string") {
throw newInvalidMessageError();
}
const keyAgreement = content.key_agreement_protocol;
const macMethod = content.message_authentication_code;
const hashCommitment = content.commitment;
const olmSAS = new globalThis.Olm.SAS();
try {
this.ourSASPubKey = olmSAS.get_pubkey();
await this.send(EventType.KeyVerificationKey, {
key: this.ourSASPubKey,
});
e = await this.waitForEvent(EventType.KeyVerificationKey);
// FIXME: make sure event is properly formed
content = e.getContent();
const commitmentStr = content.key + anotherjson.stringify(startContent);
// TODO: use selected hash function (when we support multiple)
if (olmutil.sha256(commitmentStr) !== hashCommitment) {
throw newMismatchedCommitmentError();
}
this.theirSASPubKey = content.key;
olmSAS.set_their_key(content.key);
await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod);
} finally {
olmSAS.free();
}
}
private async doRespondVerification(): Promise<void> {
// as m.related_to is not included in the encrypted content in e2e rooms,
// we need to make sure it is added
let content = this.channel.completedContentFromEvent(this.startEvent!);
// Note: we intersect using our pre-made lists, rather than the sets,
// so that the result will be in our order of preference. Then
// fetching the first element from the array will give our preferred
// method out of the ones offered by the other party.
const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0];
const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0];
const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0];
// FIXME: allow app to specify what SAS methods can be used
const sasMethods = intersection(content.short_authentication_string, SAS_SET);
if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) {
throw newUnknownMethodError();
}
const olmSAS = new globalThis.Olm.SAS();
try {
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content);
await this.send(EventType.KeyVerificationAccept, {
key_agreement_protocol: keyAgreement,
hash: hashMethod,
message_authentication_code: macMethod,
short_authentication_string: sasMethods,
// TODO: use selected hash function (when we support multiple)
commitment: olmutil.sha256(commitmentStr),
});
const e = await this.waitForEvent(EventType.KeyVerificationKey);
// FIXME: make sure event is properly formed
content = e.getContent();
this.theirSASPubKey = content.key;
olmSAS.set_their_key(content.key);
this.ourSASPubKey = olmSAS.get_pubkey();
await this.send(EventType.KeyVerificationKey, {
key: this.ourSASPubKey,
});
await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod);
} finally {
olmSAS.free();
}
}
private sendMAC(olmSAS: OlmSAS, method: MacMethod): Promise<void> {
const mac: Record<string, string> = {};
const keyList: string[] = [];
const baseInfo =
"MATRIX_KEY_VERIFICATION_MAC" +
this.baseApis.getUserId() +
this.baseApis.deviceId +
this.userId +
this.deviceId +
this.channel.transactionId;
const deviceKeyId = `ed25519:${this.baseApis.deviceId}`;
mac[deviceKeyId] = calculateMAC(olmSAS, method)(this.baseApis.getDeviceEd25519Key()!, baseInfo + deviceKeyId);
keyList.push(deviceKeyId);
const crossSigningId = this.baseApis.getCrossSigningId();
if (crossSigningId) {
const crossSigningKeyId = `ed25519:${crossSigningId}`;
mac[crossSigningKeyId] = calculateMAC(olmSAS, method)(crossSigningId, baseInfo + crossSigningKeyId);
keyList.push(crossSigningKeyId);
}
const keys = calculateMAC(olmSAS, method)(keyList.sort().join(","), baseInfo + "KEY_IDS");
return this.send(EventType.KeyVerificationMac, { mac, keys });
}
private async checkMAC(olmSAS: OlmSAS, content: IContent, method: MacMethod): Promise<void> {
const baseInfo =
"MATRIX_KEY_VERIFICATION_MAC" +
this.userId +
this.deviceId +
this.baseApis.getUserId() +
this.baseApis.deviceId +
this.channel.transactionId;
if (
content.keys !==
calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS")
) {
throw newKeyMismatchError();
}
await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => {
if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) {
throw newKeyMismatchError();
}
});
}
public getShowSasCallbacks(): ShowSasCallbacks | null {
return this.sasEvent ?? null;
}
}

View File

@ -1,37 +0,0 @@
/*
Copyright 2018 - 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.
*/
/**
* Implementation of decimal encoding of SAS as per:
* https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal
* @param sasBytes - the five bytes generated by HKDF
* @returns the derived three numbers between 1000 and 9191 inclusive
*/
export function generateDecimalSas(sasBytes: number[]): [number, number, number] {
/*
* +--------+--------+--------+--------+--------+
* | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
* +--------+--------+--------+--------+--------+
* bits: 87654321 87654321 87654321 87654321 87654321
* \____________/\_____________/\____________/
* 1st number 2nd number 3rd number
*/
return [
((sasBytes[0] << 5) | (sasBytes[1] >> 3)) + 1000,
(((sasBytes[1] & 0x7) << 10) | (sasBytes[2] << 2) | (sasBytes[3] >> 6)) + 1000,
(((sasBytes[3] & 0x3f) << 7) | (sasBytes[4] >> 1)) + 1000,
];
}

View File

@ -1,34 +0,0 @@
/*
Copyright 2021 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 { type MatrixEvent } from "../../../models/event.ts";
import { type VerificationRequest } from "./VerificationRequest.ts";
export interface IVerificationChannel {
request?: VerificationRequest;
readonly userId?: string;
readonly roomId?: string;
readonly deviceId?: string;
readonly transactionId?: string;
readonly receiveStartFromOtherDevices?: boolean;
getTimestamp(event: MatrixEvent): number;
send(type: string, uncompletedContent: Record<string, any>): Promise<void>;
completeContent(type: string, content: Record<string, any>): Record<string, any>;
sendCompleted(type: string, content: Record<string, any>): Promise<void>;
completedContentFromEvent(event: MatrixEvent): Record<string, any>;
canCreateRequest(type: string): boolean;
handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent: boolean): Promise<void>;
}

View File

@ -1,371 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest.ts";
import { logger } from "../../../logger.ts";
import { type IVerificationChannel } from "./Channel.ts";
import { EventType, type TimelineEvents } from "../../../@types/event.ts";
import { type MatrixClient } from "../../../client.ts";
import { type MatrixEvent } from "../../../models/event.ts";
import { type IRequestsMap } from "../../index.ts";
const MESSAGE_TYPE = EventType.RoomMessage;
const M_REFERENCE = "m.reference";
const M_RELATES_TO = "m.relates_to";
/**
* A key verification channel that sends verification events in the timeline of a room.
* Uses the event id of the initial m.key.verification.request event as a transaction id.
*/
export class InRoomChannel implements IVerificationChannel {
private requestEventId?: string;
/**
* @param client - the matrix client, to send messages with and get current user & device from.
* @param roomId - id of the room where verification events should be posted in, should be a DM with the given user.
* @param userId - id of user that the verification request is directed at, should be present in the room.
*/
public constructor(
private readonly client: MatrixClient,
public readonly roomId: string,
public userId?: string,
) {}
public get receiveStartFromOtherDevices(): boolean {
return true;
}
/** The transaction id generated/used by this verification channel */
public get transactionId(): string | undefined {
return this.requestEventId;
}
public static getOtherPartyUserId(event: MatrixEvent, client: MatrixClient): string | undefined {
const type = InRoomChannel.getEventType(event);
if (type !== REQUEST_TYPE) {
return;
}
const ownUserId = client.getUserId();
const sender = event.getSender();
const content = event.getContent();
const receiver = content.to;
if (sender === ownUserId) {
return receiver;
} else if (receiver === ownUserId) {
return sender;
}
}
/**
* @param event - the event to get the timestamp of
* @returns the timestamp when the event was sent
*/
public getTimestamp(event: MatrixEvent): number {
return event.getTs();
}
/**
* Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel
* @param type - the event type to check
* @returns boolean flag
*/
public static canCreateRequest(type: string): boolean {
return type === REQUEST_TYPE;
}
public canCreateRequest(type: string): boolean {
return InRoomChannel.canCreateRequest(type);
}
/**
* Extract the transaction id used by a given key verification event, if any
* @param event - the event
* @returns the transaction id
*/
public static getTransactionId(event: MatrixEvent): string | undefined {
if (InRoomChannel.getEventType(event) === REQUEST_TYPE) {
return event.getId();
} else {
const relation = event.getRelation();
if (relation?.rel_type === M_REFERENCE) {
return relation.event_id;
}
}
}
/**
* Checks whether this event is a well-formed key verification event.
* This only does checks that don't rely on the current state of a potentially already channel
* so we can prevent channels being created by invalid events.
* `handleEvent` can do more checks and choose to ignore invalid events.
* @param event - the event to validate
* @param client - the client to get the current user and device id from
* @returns whether the event is valid and should be passed to handleEvent
*/
public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean {
const txnId = InRoomChannel.getTransactionId(event);
if (typeof txnId !== "string" || txnId.length === 0) {
return false;
}
const type = InRoomChannel.getEventType(event);
const content = event.getContent();
// from here on we're fairly sure that this is supposed to be
// part of a verification request, so be noisy when rejecting something
if (type === REQUEST_TYPE) {
if (!content || typeof content.to !== "string" || !content.to.length) {
logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to);
return false;
}
// ignore requests that are not direct to or sent by the syncing user
if (!InRoomChannel.getOtherPartyUserId(event, client)) {
logger.log(
"InRoomChannel: validateEvent: " +
`not directed to or sent by me: ${event.getSender()}` +
`, ${content.to}`,
);
return false;
}
}
return VerificationRequest.validateEvent(type, event, client);
}
/**
* As m.key.verification.request events are as m.room.message events with the InRoomChannel
* to have a fallback message in non-supporting clients, we map the real event type
* to the symbolic one to keep things in unison with ToDeviceChannel
* @param event - the event to get the type of
* @returns the "symbolic" event type
*/
public static getEventType(event: MatrixEvent): string {
const type = event.getType();
if (type === MESSAGE_TYPE) {
const content = event.getContent();
if (content) {
const { msgtype } = content;
if (msgtype === REQUEST_TYPE) {
return REQUEST_TYPE;
}
}
}
if (type && type !== REQUEST_TYPE) {
return type;
} else {
return "";
}
}
/**
* Changes the state of the channel, request, and verifier in response to a key verification event.
* @param event - to handle
* @param request - the request to forward handling to
* @param isLiveEvent - whether this is an even received through sync or not
* @returns a promise that resolves when any requests as an answer to the passed-in event are sent.
*/
public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise<void> {
// prevent processing the same event multiple times, as under
// some circumstances Room.timeline can get emitted twice for the same event
if (request.hasEventId(event.getId()!)) {
return;
}
const type = InRoomChannel.getEventType(event);
// do validations that need state (roomId, userId),
// ignore if invalid
if (event.getRoomId() !== this.roomId) {
return;
}
// set userId if not set already
if (!this.userId) {
const userId = InRoomChannel.getOtherPartyUserId(event, this.client);
if (userId) {
this.userId = userId;
}
}
// ignore events not sent by us or the other party
const ownUserId = this.client.getUserId();
const sender = event.getSender();
if (this.userId) {
if (sender !== ownUserId && sender !== this.userId) {
logger.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`);
return;
}
}
if (!this.requestEventId) {
this.requestEventId = InRoomChannel.getTransactionId(event);
}
// With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected
// back via /sync. These are "local echoes" and are identifiable by their txnId
const isLocalEcho = !!event.getTxnId();
// Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes"
// and have a transaction ID in the "unsigned" data
const isRemoteEcho = !!event.getUnsigned().transaction_id;
const isSentByUs = event.getSender() === this.client.getUserId();
return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs);
}
/**
* Adds the transaction id (relation) back to a received event
* so it has the same format as returned by `completeContent` before sending.
* The relation can not appear on the event content because of encryption,
* relations are excluded from encryption.
* @param event - the received event
* @returns the content object with the relation added again
*/
public completedContentFromEvent(event: MatrixEvent): Record<string, any> {
// ensure m.related_to is included in e2ee rooms
// as the field is excluded from encryption
const content = Object.assign({}, event.getContent());
content[M_RELATES_TO] = event.getRelation()!;
return content;
}
/**
* Add all the fields to content needed for sending it over this channel.
* This is public so verification methods (SAS uses this) can get the exact
* content that will be sent independent of the used channel,
* as they need to calculate the hash of it.
* @param type - the event type
* @param content - the (incomplete) content
* @returns the complete content, as it will be sent.
*/
public completeContent(type: string, content: Record<string, any>): Record<string, any> {
content = Object.assign({}, content);
if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
content.from_device = this.client.getDeviceId();
}
if (type === REQUEST_TYPE) {
// type is mapped to m.room.message in the send method
content = {
body:
this.client.getUserId() +
" is requesting to verify " +
"your key, but your client does not support in-chat key " +
"verification. You will need to use legacy key " +
"verification to verify keys.",
msgtype: REQUEST_TYPE,
to: this.userId,
from_device: content.from_device,
methods: content.methods,
};
} else {
content[M_RELATES_TO] = {
rel_type: M_REFERENCE,
event_id: this.transactionId,
};
}
return content;
}
/**
* Send an event over the channel with the content not having gone through `completeContent`.
* @param type - the event type
* @param uncompletedContent - the (incomplete) content
* @returns the promise of the request
*/
public send(type: string, uncompletedContent: Record<string, any>): Promise<void> {
const content = this.completeContent(type, uncompletedContent);
return this.sendCompleted(type, content);
}
/**
* Send an event over the channel with the content having gone through `completeContent` already.
* @param type - the event type
* @returns the promise of the request
*/
public async sendCompleted(type: string, content: Record<string, any>): Promise<void> {
let sendType = type;
if (type === REQUEST_TYPE) {
sendType = MESSAGE_TYPE;
}
const response = await this.client.sendEvent(
this.roomId,
sendType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
if (type === REQUEST_TYPE) {
this.requestEventId = response.event_id;
}
}
}
export class InRoomRequests implements IRequestsMap {
private requestsByRoomId = new Map<string, Map<string, VerificationRequest>>();
public getRequest(event: MatrixEvent): VerificationRequest | undefined {
const roomId = event.getRoomId()!;
const txnId = InRoomChannel.getTransactionId(event)!;
return this.getRequestByTxnId(roomId, txnId);
}
public getRequestByChannel(channel: InRoomChannel): VerificationRequest | undefined {
return this.getRequestByTxnId(channel.roomId, channel.transactionId!);
}
private getRequestByTxnId(roomId: string, txnId: string): VerificationRequest | undefined {
const requestsByTxnId = this.requestsByRoomId.get(roomId);
if (requestsByTxnId) {
return requestsByTxnId.get(txnId);
}
}
public setRequest(event: MatrixEvent, request: VerificationRequest): void {
this.doSetRequest(event.getRoomId()!, InRoomChannel.getTransactionId(event)!, request);
}
public setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void {
this.doSetRequest(channel.roomId!, channel.transactionId!, request);
}
private doSetRequest(roomId: string, txnId: string, request: VerificationRequest): void {
let requestsByTxnId = this.requestsByRoomId.get(roomId);
if (!requestsByTxnId) {
requestsByTxnId = new Map();
this.requestsByRoomId.set(roomId, requestsByTxnId);
}
requestsByTxnId.set(txnId, request);
}
public removeRequest(event: MatrixEvent): void {
const roomId = event.getRoomId()!;
const requestsByTxnId = this.requestsByRoomId.get(roomId);
if (requestsByTxnId) {
requestsByTxnId.delete(InRoomChannel.getTransactionId(event)!);
if (requestsByTxnId.size === 0) {
this.requestsByRoomId.delete(roomId);
}
}
}
public findRequestInProgress(roomId: string, userId?: string): VerificationRequest | undefined {
const requestsByTxnId = this.requestsByRoomId.get(roomId);
if (requestsByTxnId) {
for (const request of requestsByTxnId.values()) {
if (request.pending && (userId === undefined || request.requestingUserId === userId)) {
return request;
}
}
}
}
}

View File

@ -1,354 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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 { secureRandomString } from "../../../randomstring.ts";
import { logger } from "../../../logger.ts";
import {
CANCEL_TYPE,
PHASE_STARTED,
PHASE_READY,
REQUEST_TYPE,
READY_TYPE,
START_TYPE,
VerificationRequest,
} from "./VerificationRequest.ts";
import { errorFromEvent, newUnexpectedMessageError } from "../Error.ts";
import { MatrixEvent } from "../../../models/event.ts";
import { type IVerificationChannel } from "./Channel.ts";
import { type MatrixClient } from "../../../client.ts";
import { type IRequestsMap } from "../../index.ts";
export type Request = VerificationRequest<ToDeviceChannel>;
/**
* A key verification channel that sends verification events over to_device messages.
* Generates its own transaction ids.
*/
export class ToDeviceChannel implements IVerificationChannel {
public request?: VerificationRequest;
// userId and devices of user we're about to verify
public constructor(
private readonly client: MatrixClient,
public readonly userId: string,
private readonly devices: string[],
public transactionId?: string,
public deviceId?: string,
) {}
public isToDevices(devices: string[]): boolean {
if (devices.length === this.devices.length) {
for (const device of devices) {
if (!this.devices.includes(device)) {
return false;
}
}
return true;
} else {
return false;
}
}
public static getEventType(event: MatrixEvent): string {
return event.getType();
}
/**
* Extract the transaction id used by a given key verification event, if any
* @param event - the event
* @returns the transaction id
*/
public static getTransactionId(event: MatrixEvent): string {
const content = event.getContent();
return content && content.transaction_id;
}
/**
* Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel
* @param type - the event type to check
* @returns boolean flag
*/
public static canCreateRequest(type: string): boolean {
return type === REQUEST_TYPE || type === START_TYPE;
}
public canCreateRequest(type: string): boolean {
return ToDeviceChannel.canCreateRequest(type);
}
/**
* Checks whether this event is a well-formed key verification event.
* This only does checks that don't rely on the current state of a potentially already channel
* so we can prevent channels being created by invalid events.
* `handleEvent` can do more checks and choose to ignore invalid events.
* @param event - the event to validate
* @param client - the client to get the current user and device id from
* @returns whether the event is valid and should be passed to handleEvent
*/
public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean {
if (event.isCancelled()) {
logger.warn("Ignoring flagged verification request from " + event.getSender());
return false;
}
const content = event.getContent();
if (!content) {
logger.warn("ToDeviceChannel.validateEvent: invalid: no content");
return false;
}
if (!content.transaction_id) {
logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id");
return false;
}
const type = event.getType();
if (type === REQUEST_TYPE) {
if (!Number.isFinite(content.timestamp)) {
logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp");
return false;
}
if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) {
// ignore requests from ourselves, because it doesn't make sense for a
// device to verify itself
logger.warn("ToDeviceChannel.validateEvent: invalid: from own device");
return false;
}
}
return VerificationRequest.validateEvent(type, event, client);
}
/**
* @param event - the event to get the timestamp of
* @returns the timestamp when the event was sent
*/
public getTimestamp(event: MatrixEvent): number {
const content = event.getContent();
return content && content.timestamp;
}
/**
* Changes the state of the channel, request, and verifier in response to a key verification event.
* @param event - to handle
* @param request - the request to forward handling to
* @param isLiveEvent - whether this is an even received through sync or not
* @returns a promise that resolves when any requests as an answer to the passed-in event are sent.
*/
public async handleEvent(event: MatrixEvent, request: Request, isLiveEvent = false): Promise<void> {
const type = event.getType();
const content = event.getContent();
if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
if (!this.transactionId) {
this.transactionId = content.transaction_id;
}
const deviceId = content.from_device;
// adopt deviceId if not set before and valid
if (!this.deviceId && this.devices.includes(deviceId)) {
this.deviceId = deviceId;
}
// if no device id or different from adopted one, cancel with sender
if (!this.deviceId || this.deviceId !== deviceId) {
// also check that message came from the device we sent the request to earlier on
// and do send a cancel message to that device
// (but don't cancel the request for the device we should be talking to)
const cancelContent = this.completeContent(CANCEL_TYPE, errorFromEvent(newUnexpectedMessageError()));
return this.sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]);
}
}
const wasStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY;
await request.handleEvent(event.getType(), event, isLiveEvent, false, false);
const isStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY;
const isAcceptingEvent = type === START_TYPE || type === READY_TYPE;
// the request has picked a ready or start event, tell the other devices about it
if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) {
const nonChosenDevices = this.devices.filter((d) => d !== this.deviceId && d !== this.client.getDeviceId());
if (nonChosenDevices.length) {
const message = this.completeContent(CANCEL_TYPE, {
code: "m.accepted",
reason: "Verification request accepted by another device",
});
await this.sendToDevices(CANCEL_TYPE, message, nonChosenDevices);
}
}
}
/**
* See {@link InRoomChannel#completedContentFromEvent} for why this is needed.
* @param event - the received event
* @returns the content object
*/
public completedContentFromEvent(event: MatrixEvent): Record<string, any> {
return event.getContent();
}
/**
* Add all the fields to content needed for sending it over this channel.
* This is public so verification methods (SAS uses this) can get the exact
* content that will be sent independent of the used channel,
* as they need to calculate the hash of it.
* @param type - the event type
* @param content - the (incomplete) content
* @returns the complete content, as it will be sent.
*/
public completeContent(type: string, content: Record<string, any>): Record<string, any> {
// make a copy
content = Object.assign({}, content);
if (this.transactionId) {
content.transaction_id = this.transactionId;
}
if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
content.from_device = this.client.getDeviceId();
}
if (type === REQUEST_TYPE) {
content.timestamp = Date.now();
}
return content;
}
/**
* Send an event over the channel with the content not having gone through `completeContent`.
* @param type - the event type
* @param uncompletedContent - the (incomplete) content
* @returns the promise of the request
*/
public send(type: string, uncompletedContent: Record<string, any> = {}): Promise<void> {
// create transaction id when sending request
if ((type === REQUEST_TYPE || type === START_TYPE) && !this.transactionId) {
this.transactionId = ToDeviceChannel.makeTransactionId();
}
const content = this.completeContent(type, uncompletedContent);
return this.sendCompleted(type, content);
}
/**
* Send an event over the channel with the content having gone through `completeContent` already.
* @param type - the event type
* @returns the promise of the request
*/
public async sendCompleted(type: string, content: Record<string, any>): Promise<void> {
let result;
if (type === REQUEST_TYPE || (type === CANCEL_TYPE && !this.deviceId)) {
result = await this.sendToDevices(type, content, this.devices);
} else {
result = await this.sendToDevices(type, content, [this.deviceId!]);
}
// the VerificationRequest state machine requires remote echos of the event
// the client sends itself, so we fake this for to_device messages
const remoteEchoEvent = new MatrixEvent({
sender: this.client.getUserId()!,
content,
type,
});
await this.request!.handleEvent(
type,
remoteEchoEvent,
/*isLiveEvent=*/ true,
/*isRemoteEcho=*/ true,
/*isSentByUs=*/ true,
);
return result;
}
private async sendToDevices(type: string, content: Record<string, any>, devices: string[]): Promise<void> {
if (devices.length) {
const deviceMessages: Map<string, Record<string, any>> = new Map();
for (const deviceId of devices) {
deviceMessages.set(deviceId, content);
}
await this.client.sendToDevice(type, new Map([[this.userId, deviceMessages]]));
}
}
/**
* Allow Crypto module to create and know the transaction id before the .start event gets sent.
* @returns the transaction id
*/
public static makeTransactionId(): string {
return secureRandomString(32);
}
}
export class ToDeviceRequests implements IRequestsMap {
private requestsByUserId = new Map<string, Map<string, Request>>();
public getRequest(event: MatrixEvent): Request | undefined {
return this.getRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event));
}
public getRequestByChannel(channel: ToDeviceChannel): Request | undefined {
return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId!);
}
public getRequestBySenderAndTxnId(sender: string, txnId: string): Request | undefined {
const requestsByTxnId = this.requestsByUserId.get(sender);
if (requestsByTxnId) {
return requestsByTxnId.get(txnId);
}
}
public setRequest(event: MatrixEvent, request: Request): void {
this.setRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event), request);
}
public setRequestByChannel(channel: ToDeviceChannel, request: Request): void {
this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId!, request);
}
public setRequestBySenderAndTxnId(sender: string, txnId: string, request: Request): void {
let requestsByTxnId = this.requestsByUserId.get(sender);
if (!requestsByTxnId) {
requestsByTxnId = new Map();
this.requestsByUserId.set(sender, requestsByTxnId);
}
requestsByTxnId.set(txnId, request);
}
public removeRequest(event: MatrixEvent): void {
const userId = event.getSender()!;
const requestsByTxnId = this.requestsByUserId.get(userId);
if (requestsByTxnId) {
requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event));
if (requestsByTxnId.size === 0) {
this.requestsByUserId.delete(userId);
}
}
}
public findRequestInProgress(userId: string, devices: string[]): Request | undefined {
const requestsByTxnId = this.requestsByUserId.get(userId);
if (requestsByTxnId) {
for (const request of requestsByTxnId.values()) {
if (request.pending && request.channel.isToDevices(devices)) {
return request;
}
}
}
}
public getRequestsInProgress(userId: string): Request[] {
const requestsByTxnId = this.requestsByUserId.get(userId);
if (requestsByTxnId) {
return Array.from(requestsByTxnId.values()).filter((r) => r.pending);
}
return [];
}
}

View File

@ -1,977 +0,0 @@
/*
Copyright 2018 - 2021 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 { logger } from "../../../logger.ts";
import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError } from "../Error.ts";
import { QRCodeData, SCAN_QR_CODE_METHOD } from "../QRCode.ts";
import { type IVerificationChannel } from "./Channel.ts";
import { type MatrixClient } from "../../../client.ts";
import { type MatrixEvent } from "../../../models/event.ts";
import { EventType } from "../../../@types/event.ts";
import { type VerificationBase } from "../Base.ts";
import { type VerificationMethod } from "../../index.ts";
import { TypedEventEmitter } from "../../../models/typed-event-emitter.ts";
import {
canAcceptVerificationRequest,
VerificationPhase as Phase,
type VerificationRequest as IVerificationRequest,
VerificationRequestEvent,
type VerificationRequestEventHandlerMap,
type Verifier,
} from "../../../crypto-api/verification.ts";
// backwards-compatibility exports
export { VerificationPhase as Phase, VerificationRequestEvent } from "../../../crypto-api/verification.ts";
// How long after the event's timestamp that the request times out
const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes
// How long after we receive the event that the request times out
const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes
// to avoid almost expired verification notifications
// from showing a notification and almost immediately
// disappearing, also ignore verification requests that
// are this amount of time away from expiring.
const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds
export const EVENT_PREFIX = "m.key.verification.";
export const REQUEST_TYPE = EVENT_PREFIX + "request";
export const START_TYPE = EVENT_PREFIX + "start";
export const CANCEL_TYPE = EVENT_PREFIX + "cancel";
export const DONE_TYPE = EVENT_PREFIX + "done";
export const READY_TYPE = EVENT_PREFIX + "ready";
// Legacy export fields
export const PHASE_UNSENT = Phase.Unsent;
export const PHASE_REQUESTED = Phase.Requested;
export const PHASE_READY = Phase.Ready;
export const PHASE_STARTED = Phase.Started;
export const PHASE_CANCELLED = Phase.Cancelled;
export const PHASE_DONE = Phase.Done;
interface ITargetDevice {
userId?: string;
deviceId?: string;
}
interface ITransition {
phase: Phase;
event?: MatrixEvent;
}
/**
* State machine for verification requests.
* Things that differ based on what channel is used to
* send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`.
*
* @deprecated Avoid direct references: instead prefer {@link Crypto.VerificationRequest}.
*/
export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel>
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap>
implements IVerificationRequest
{
private eventsByUs = new Map<string, MatrixEvent>();
private eventsByThem = new Map<string, MatrixEvent>();
private _observeOnly = false;
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
private _accepting = false;
private _declining = false;
private verifierHasFinished = false;
private _cancelled = false;
private _chosenMethod: VerificationMethod | null = null;
// we keep a copy of the QR Code data (including other user master key) around
// for QR reciprocate verification, to protect against
// cross-signing identity reset between the .ready and .start event
// and signing the wrong key after .start
private _qrCodeData: QRCodeData | null = null;
// The timestamp when we received the request event from the other side
private requestReceivedAt: number | null = null;
private commonMethods: VerificationMethod[] = [];
private _phase!: Phase;
public _cancellingUserId?: string; // Used in tests only
private _verifier?: VerificationBase<any, any>;
public constructor(
public readonly channel: C,
private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>,
private readonly client: MatrixClient,
) {
super();
this.channel.request = this;
this.setPhase(PHASE_UNSENT, false);
}
/**
* Stateless validation logic not specific to the channel.
* Invoked by the same static method in either channel.
* @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel.
* @param event - the event to validate. Don't call getType() on it but use the `type` parameter instead.
* @param client - the client to get the current user and device id from
* @returns whether the event is valid and should be passed to handleEvent
*/
public static validateEvent(type: string, event: MatrixEvent, client: MatrixClient): boolean {
const content = event.getContent();
if (!type || !type.startsWith(EVENT_PREFIX)) {
return false;
}
// from here on we're fairly sure that this is supposed to be
// part of a verification request, so be noisy when rejecting something
if (!content) {
logger.log("VerificationRequest: validateEvent: no content");
return false;
}
if (type === REQUEST_TYPE || type === READY_TYPE) {
if (!Array.isArray(content.methods)) {
logger.log("VerificationRequest: validateEvent: " + "fail because methods");
return false;
}
}
if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
if (typeof content.from_device !== "string" || content.from_device.length === 0) {
logger.log("VerificationRequest: validateEvent: " + "fail because from_device");
return false;
}
}
return true;
}
/**
* Unique ID for this verification request.
*
* An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases.
*/
public get transactionId(): string | undefined {
return this.channel.transactionId;
}
/**
* For an in-room verification, the ID of the room.
*/
public get roomId(): string | undefined {
return this.channel.roomId;
}
public get invalid(): boolean {
return this.phase === PHASE_UNSENT;
}
/** returns whether the phase is PHASE_REQUESTED */
public get requested(): boolean {
return this.phase === PHASE_REQUESTED;
}
/** returns whether the phase is PHASE_CANCELLED */
public get cancelled(): boolean {
return this.phase === PHASE_CANCELLED;
}
/** returns whether the phase is PHASE_READY */
public get ready(): boolean {
return this.phase === PHASE_READY;
}
/** returns whether the phase is PHASE_STARTED */
public get started(): boolean {
return this.phase === PHASE_STARTED;
}
/** returns whether the phase is PHASE_DONE */
public get done(): boolean {
return this.phase === PHASE_DONE;
}
/** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */
public get methods(): VerificationMethod[] {
return this.commonMethods;
}
/** the method picked in the .start event */
public get chosenMethod(): VerificationMethod | null {
return this._chosenMethod;
}
public calculateEventTimeout(event: MatrixEvent): number {
let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS;
if (this.requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED) {
const expiresAtByReceipt = this.requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT;
effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt);
}
return Math.max(0, effectiveExpiresAt - Date.now());
}
/** The current remaining amount of ms before the request should be automatically cancelled */
public get timeout(): number {
const requestEvent = this.getEventByEither(REQUEST_TYPE);
if (requestEvent) {
return this.calculateEventTimeout(requestEvent);
}
return 0;
}
/**
* The key verification request event.
* @returns The request event, or falsey if not found.
*/
public get requestEvent(): MatrixEvent | undefined {
return this.getEventByEither(REQUEST_TYPE);
}
/** current phase of the request. Some properties might only be defined in a current phase. */
public get phase(): Phase {
return this._phase;
}
/** The verifier to do the actual verification, once the method has been established. Only defined when the `phase` is PHASE_STARTED. */
public get verifier(): VerificationBase<any, any> | undefined {
return this._verifier;
}
public get canAccept(): boolean {
return canAcceptVerificationRequest(this);
}
public get accepting(): boolean {
return this._accepting;
}
public get declining(): boolean {
return this._declining;
}
/** whether this request has sent it's initial event and needs more events to complete */
public get pending(): boolean {
return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED;
}
/** Only set after a .ready if the other party can scan a QR code
*
* @deprecated Prefer `generateQRCode`.
*/
public get qrCodeData(): QRCodeData | null {
return this._qrCodeData;
}
/**
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*
* @deprecated Prefer `generateQRCode`.
*/
public getQRCodeBytes(): Uint8ClampedArray | undefined {
if (!this._qrCodeData) return;
return new Uint8ClampedArray(this._qrCodeData.getBuffer());
}
/**
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only returns data once `phase` is `Ready` and the other party can scan a QR code;
* otherwise returns `undefined`.
*/
public async generateQRCode(): Promise<Uint8ClampedArray | undefined> {
return this.getQRCodeBytes();
}
/** Checks whether the other party supports a given verification method.
* This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
* if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
* For methods that need to be supported by both ends, use the `methods` property.
* @param method - the method to check
* @param force - to check even if the phase is not ready or started yet, internal usage
* @returns whether or not the other party said the supported the method */
public otherPartySupportsMethod(method: string, force = false): boolean {
if (!force && !this.ready && !this.started) {
return false;
}
const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE);
if (!theirMethodEvent) {
// if we started straight away with .start event,
// we are assuming that the other side will support the
// chosen method, so return true for that.
if (this.started && this.initiatedByMe) {
const myStartEvent = this.eventsByUs.get(START_TYPE);
const content = myStartEvent && myStartEvent.getContent();
const myStartMethod = content && content.method;
return method == myStartMethod;
}
return false;
}
const content = theirMethodEvent.getContent();
if (!content) {
return false;
}
const { methods } = content;
if (!Array.isArray(methods)) {
return false;
}
return methods.includes(method);
}
/** Whether this request was initiated by the syncing user.
* For InRoomChannel, this is who sent the .request event.
* For ToDeviceChannel, this is who sent the .start event
*/
public get initiatedByMe(): boolean {
// event created by us but no remote echo has been received yet
const noEventsYet = this.eventsByUs.size + this.eventsByThem.size === 0;
if (this._phase === PHASE_UNSENT && noEventsYet) {
return true;
}
const hasMyRequest = this.eventsByUs.has(REQUEST_TYPE);
const hasTheirRequest = this.eventsByThem.has(REQUEST_TYPE);
if (hasMyRequest && !hasTheirRequest) {
return true;
}
if (!hasMyRequest && hasTheirRequest) {
return false;
}
const hasMyStart = this.eventsByUs.has(START_TYPE);
const hasTheirStart = this.eventsByThem.has(START_TYPE);
if (hasMyStart && !hasTheirStart) {
return true;
}
return false;
}
/** The id of the user that initiated the request */
public get requestingUserId(): string {
if (this.initiatedByMe) {
return this.client.getUserId()!;
} else {
return this.otherUserId;
}
}
/** The id of the user that (will) receive(d) the request */
public get receivingUserId(): string {
if (this.initiatedByMe) {
return this.otherUserId;
} else {
return this.client.getUserId()!;
}
}
/** The user id of the other party in this request */
public get otherUserId(): string {
return this.channel.userId!;
}
/** The device id of the other party in this request, for requests happening over to-device messages only. */
public get otherDeviceId(): string | undefined {
return this.channel.deviceId;
}
public get isSelfVerification(): boolean {
return this.client.getUserId() === this.otherUserId;
}
/**
* The id of the user that cancelled the request,
* only defined when phase is PHASE_CANCELLED
*/
public get cancellingUserId(): string | undefined {
const myCancel = this.eventsByUs.get(CANCEL_TYPE);
const theirCancel = this.eventsByThem.get(CANCEL_TYPE);
if (myCancel && (!theirCancel || myCancel.getId()! < theirCancel.getId()!)) {
return myCancel.getSender();
}
if (theirCancel) {
return theirCancel.getSender();
}
return undefined;
}
/**
* The cancellation code e.g m.user which is responsible for cancelling this verification
*/
public get cancellationCode(): string {
const ev = this.getEventByEither(CANCEL_TYPE);
return ev ? ev.getContent().code : null;
}
public get observeOnly(): boolean {
return this._observeOnly;
}
/**
* Gets which device the verification should be started with
* given the events sent so far in the verification. This is the
* same algorithm used to determine which device to send the
* verification to when no specific device is specified.
* @returns The device information
*/
public get targetDevice(): ITargetDevice {
const theirFirstEvent =
this.eventsByThem.get(REQUEST_TYPE) ||
this.eventsByThem.get(READY_TYPE) ||
this.eventsByThem.get(START_TYPE);
const theirFirstContent = theirFirstEvent?.getContent();
const fromDevice = theirFirstContent?.from_device;
return {
userId: this.otherUserId,
deviceId: fromDevice,
};
}
/* Start the key verification, creating a verifier and sending a .start event.
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
* @param method - the name of the verification method to use.
* @param targetDevice.userId the id of the user to direct this request to
* @param targetDevice.deviceId the id of the device to direct this request to
* @returns the verifier of the given method
*/
public beginKeyVerification(
method: VerificationMethod,
targetDevice: ITargetDevice | null = null,
): VerificationBase<any, any> {
// need to allow also when unsent in case of to_device
if (!this.observeOnly && !this._verifier) {
const validStartPhase =
this.phase === PHASE_REQUESTED ||
this.phase === PHASE_READY ||
(this.phase === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE));
if (validStartPhase) {
// when called on a request that was initiated with .request event
// check the method is supported by both sides
if (this.commonMethods.length && !this.commonMethods.includes(method)) {
throw newUnknownMethodError();
}
this._verifier = this.createVerifier(method, null, targetDevice);
if (!this._verifier) {
throw newUnknownMethodError();
}
this._chosenMethod = method;
}
}
return this._verifier!;
}
public async startVerification(method: string): Promise<Verifier> {
const verifier = this.beginKeyVerification(method);
// kick off the verification in the background, but *don't* wait for to complete: we need to return the `Verifier`.
verifier.verify();
return verifier;
}
public scanQRCode(qrCodeData: Uint8ClampedArray): Promise<Verifier> {
throw new Error("QR code scanning not supported by legacy crypto");
}
/**
* sends the initial .request event.
* @returns resolves when the event has been sent.
*/
public async sendRequest(): Promise<void> {
if (!this.observeOnly && this._phase === PHASE_UNSENT) {
const methods = [...this.verificationMethods.keys()];
await this.channel.send(REQUEST_TYPE, { methods });
}
}
/**
* Cancels the request, sending a cancellation to the other party
* @param params
* @param params.reason - the error reason to send the cancellation with
* @param params.code - the error code to send the cancellation with
* @returns resolves when the event has been sent.
*/
public async cancel({ reason = "User declined", code = "m.user" } = {}): Promise<void> {
if (!this.observeOnly && this._phase !== PHASE_CANCELLED) {
this._declining = true;
this.emit(VerificationRequestEvent.Change);
if (this._verifier) {
return this._verifier.cancel(errorFactory(code, reason)());
} else {
this._cancellingUserId = this.client.getUserId()!;
await this.channel.send(CANCEL_TYPE, { code, reason });
}
}
}
/**
* Accepts the request, sending a .ready event to the other party
* @returns resolves when the event has been sent.
*/
public async accept(): Promise<void> {
if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) {
const methods = [...this.verificationMethods.keys()];
this._accepting = true;
this.emit(VerificationRequestEvent.Change);
await this.channel.send(READY_TYPE, { methods });
}
}
/**
* Can be used to listen for state changes until the callback returns true.
* @param fn - callback to evaluate whether the request is in the desired state.
* Takes the request as an argument.
* @returns that resolves once the callback returns true
* @throws Error when the request is cancelled
*/
public waitFor(fn: (request: VerificationRequest) => boolean): Promise<VerificationRequest> {
return new Promise((resolve, reject) => {
const check = (): boolean => {
let handled = false;
if (fn(this)) {
resolve(this);
handled = true;
} else if (this.cancelled) {
reject(new Error("cancelled"));
handled = true;
}
if (handled) {
this.off(VerificationRequestEvent.Change, check);
}
return handled;
};
if (!check()) {
this.on(VerificationRequestEvent.Change, check);
}
});
}
private setPhase(phase: Phase, notify = true): void {
this._phase = phase;
if (notify) {
this.emit(VerificationRequestEvent.Change);
}
}
private getEventByEither(type: string): MatrixEvent | undefined {
return this.eventsByThem.get(type) || this.eventsByUs.get(type);
}
private getEventBy(type: string, byThem = false): MatrixEvent | undefined {
if (byThem) {
return this.eventsByThem.get(type);
} else {
return this.eventsByUs.get(type);
}
}
private calculatePhaseTransitions(): ITransition[] {
const transitions: ITransition[] = [{ phase: PHASE_UNSENT }];
const phase = (): Phase => transitions[transitions.length - 1].phase;
// always pass by .request first to be sure channel.userId has been set
const hasRequestByThem = this.eventsByThem.has(REQUEST_TYPE);
const requestEvent = this.getEventBy(REQUEST_TYPE, hasRequestByThem);
if (requestEvent) {
transitions.push({ phase: PHASE_REQUESTED, event: requestEvent });
}
const readyEvent = requestEvent && this.getEventBy(READY_TYPE, !hasRequestByThem);
if (readyEvent && phase() === PHASE_REQUESTED) {
transitions.push({ phase: PHASE_READY, event: readyEvent });
}
let startEvent: MatrixEvent | undefined;
if (readyEvent || !requestEvent) {
const theirStartEvent = this.eventsByThem.get(START_TYPE);
const ourStartEvent = this.eventsByUs.get(START_TYPE);
// any party can send .start after a .ready or unsent
if (theirStartEvent && ourStartEvent) {
startEvent =
theirStartEvent.getSender()! < ourStartEvent.getSender()! ? theirStartEvent : ourStartEvent;
} else {
startEvent = theirStartEvent ? theirStartEvent : ourStartEvent;
}
} else {
startEvent = this.getEventBy(START_TYPE, !hasRequestByThem);
}
if (startEvent) {
const fromRequestPhase =
phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender();
const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE);
if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) {
transitions.push({ phase: PHASE_STARTED, event: startEvent });
}
}
const ourDoneEvent = this.eventsByUs.get(DONE_TYPE);
if (this.verifierHasFinished || (ourDoneEvent && phase() === PHASE_STARTED)) {
transitions.push({ phase: PHASE_DONE });
}
const cancelEvent = this.getEventByEither(CANCEL_TYPE);
if ((this._cancelled || cancelEvent) && phase() !== PHASE_DONE) {
transitions.push({ phase: PHASE_CANCELLED, event: cancelEvent });
return transitions;
}
return transitions;
}
private transitionToPhase(transition: ITransition): void {
const { phase, event } = transition;
// get common methods
if (phase === PHASE_REQUESTED || phase === PHASE_READY) {
if (!this.wasSentByOwnDevice(event)) {
const content = event!.getContent<{
methods: string[];
}>();
this.commonMethods = content.methods.filter((m) => this.verificationMethods.has(m));
}
}
// detect if we're not a party in the request, and we should just observe
if (!this.observeOnly) {
// if requested or accepted by one of my other devices
if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY) {
if (
this.channel.receiveStartFromOtherDevices &&
this.wasSentByOwnUser(event) &&
!this.wasSentByOwnDevice(event)
) {
this._observeOnly = true;
}
}
}
// create verifier
if (phase === PHASE_STARTED) {
const { method } = event!.getContent();
if (!this._verifier && !this.observeOnly) {
this._verifier = this.createVerifier(method, event);
if (!this._verifier) {
this.cancel({
code: "m.unknown_method",
reason: `Unknown method: ${method}`,
});
} else {
this._chosenMethod = method;
}
}
}
}
private applyPhaseTransitions(): ITransition[] {
const transitions = this.calculatePhaseTransitions();
const existingIdx = transitions.findIndex((t) => t.phase === this.phase);
// trim off phases we already went through, if any
const newTransitions = transitions.slice(existingIdx + 1);
// transition to all new phases
for (const transition of newTransitions) {
this.transitionToPhase(transition);
}
return newTransitions;
}
private isWinningStartRace(newEvent: MatrixEvent): boolean {
if (newEvent.getType() !== START_TYPE) {
return false;
}
const oldEvent = this._verifier!.startEvent;
let oldRaceIdentifier;
if (this.isSelfVerification) {
// if the verifier does not have a startEvent,
// it is because it's still sending and we are on the initator side
// we know we are sending a .start event because we already
// have a verifier (checked in calling method)
if (oldEvent) {
const oldContent = oldEvent.getContent();
oldRaceIdentifier = oldContent && oldContent.from_device;
} else {
oldRaceIdentifier = this.client.getDeviceId();
}
} else {
if (oldEvent) {
oldRaceIdentifier = oldEvent.getSender();
} else {
oldRaceIdentifier = this.client.getUserId();
}
}
let newRaceIdentifier;
if (this.isSelfVerification) {
const newContent = newEvent.getContent();
newRaceIdentifier = newContent && newContent.from_device;
} else {
newRaceIdentifier = newEvent.getSender();
}
return newRaceIdentifier < oldRaceIdentifier;
}
public hasEventId(eventId: string): boolean {
for (const event of this.eventsByUs.values()) {
if (event.getId() === eventId) {
return true;
}
}
for (const event of this.eventsByThem.values()) {
if (event.getId() === eventId) {
return true;
}
}
return false;
}
/**
* Changes the state of the request and verifier in response to a key verification event.
* @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel.
* @param event - the event to handle. Don't call getType() on it but use the `type` parameter instead.
* @param isLiveEvent - whether this is an even received through sync or not
* @param isRemoteEcho - whether this is the remote echo of an event sent by the same device
* @param isSentByUs - whether this event is sent by a party that can accept and/or observe the request like one of our peers.
* For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device.
* @returns a promise that resolves when any requests as an answer to the passed-in event are sent.
*/
public async handleEvent(
type: string,
event: MatrixEvent,
isLiveEvent: boolean,
isRemoteEcho: boolean,
isSentByUs: boolean,
): Promise<void> {
// if reached phase cancelled or done, ignore anything else that comes
if (this.done || this.cancelled) {
return;
}
const wasObserveOnly = this._observeOnly;
this.adjustObserveOnly(event, isLiveEvent);
if (!this.observeOnly && !isRemoteEcho) {
if (await this.cancelOnError(type, event)) {
return;
}
}
// This assumes verification won't need to send an event with
// the same type for the same party twice.
// This is true for QR and SAS verification, and was
// added here to prevent verification getting cancelled
// when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365)
const isDuplicateEvent = isSentByUs ? this.eventsByUs.has(type) : this.eventsByThem.has(type);
if (isDuplicateEvent) {
return;
}
const oldPhase = this.phase;
this.addEvent(type, event, isSentByUs);
// this will create if needed the verifier so needs to happen before calling it
const newTransitions = this.applyPhaseTransitions();
try {
// only pass events from the other side to the verifier,
// no remote echos of our own events
if (this._verifier && !this.observeOnly) {
const newEventWinsRace = this.isWinningStartRace(event);
if (this._verifier.canSwitchStartEvent(event) && newEventWinsRace) {
this._verifier.switchStartEvent(event);
} else if (!isRemoteEcho) {
if (type === CANCEL_TYPE || this._verifier.events?.includes(type)) {
this._verifier.handleEvent(event);
}
}
}
if (newTransitions.length) {
// create QRCodeData if the other side can scan
// important this happens before emitting a phase change,
// so listeners can rely on it being there already
// We only do this for live events because it is important that
// we sign the keys that were in the QR code, and not the keys
// we happen to have at some later point in time.
if (isLiveEvent && newTransitions.some((t) => t.phase === PHASE_READY)) {
const shouldGenerateQrCode = this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true);
if (shouldGenerateQrCode) {
this._qrCodeData = await QRCodeData.create(this, this.client);
}
}
const lastTransition = newTransitions[newTransitions.length - 1];
const { phase } = lastTransition;
this.setupTimeout(phase);
// set phase as last thing as this emits the "change" event
this.setPhase(phase);
} else if (this._observeOnly !== wasObserveOnly) {
this.emit(VerificationRequestEvent.Change);
}
} finally {
// log events we processed so we can see from rageshakes what events were added to a request
logger.log(
`Verification request ${this.channel.transactionId}: ` +
`${type} event with id:${event.getId()}, ` +
`content:${JSON.stringify(event.getContent())} ` +
`deviceId:${this.channel.deviceId}, ` +
`sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` +
`isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` +
`phase:${oldPhase}=>${this.phase}, ` +
`observeOnly:${wasObserveOnly}=>${this._observeOnly}`,
);
}
}
private setupTimeout(phase: Phase): void {
const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED;
if (shouldTimeout) {
this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout);
}
if (this.timeoutTimer) {
const shouldClear =
phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED;
if (shouldClear) {
clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
}
}
private cancelOnTimeout = async (): Promise<void> => {
try {
if (this.initiatedByMe) {
await this.cancel({
reason: "Other party didn't accept in time",
code: "m.timeout",
});
} else {
await this.cancel({
reason: "User didn't accept in time",
code: "m.timeout",
});
}
} catch (err) {
logger.error("Error while cancelling verification request", err);
}
};
private async cancelOnError(type: string, event: MatrixEvent): Promise<boolean> {
if (type === START_TYPE) {
const method = event.getContent().method;
if (!this.verificationMethods.has(method)) {
await this.cancel(errorFromEvent(newUnknownMethodError()));
return true;
}
}
const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT;
const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED;
// only if phase has passed from PHASE_UNSENT should we cancel, because events
// are allowed to come in in any order (at least with InRoomChannel). So we only know
// we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED.
// Before that, we could be looking at somebody else's verification request and we just
// happen to be in the room
if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) {
logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`);
const reason = `Unexpected ${type} event in phase ${this.phase}`;
await this.cancel(errorFromEvent(newUnexpectedMessageError({ reason })));
return true;
}
return false;
}
private adjustObserveOnly(event: MatrixEvent, isLiveEvent = false): void {
// don't send out events for historical requests
if (!isLiveEvent) {
this._observeOnly = true;
}
if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) {
this._observeOnly = true;
}
}
private addEvent(type: string, event: MatrixEvent, isSentByUs = false): void {
if (isSentByUs) {
this.eventsByUs.set(type, event);
} else {
this.eventsByThem.set(type, event);
}
// once we know the userId of the other party (from the .request event)
// see if any event by anyone else crept into this.eventsByThem
if (type === REQUEST_TYPE) {
for (const [type, event] of this.eventsByThem.entries()) {
if (event.getSender() !== this.otherUserId) {
this.eventsByThem.delete(type);
}
}
// also remember when we received the request event
this.requestReceivedAt = Date.now();
}
}
private createVerifier(
method: VerificationMethod,
startEvent: MatrixEvent | null = null,
targetDevice: ITargetDevice | null = null,
): VerificationBase<any, any> | undefined {
if (!targetDevice) {
targetDevice = this.targetDevice;
}
const { userId, deviceId } = targetDevice;
const VerifierCtor = this.verificationMethods.get(method);
if (!VerifierCtor) {
logger.warn("could not find verifier constructor for method", method);
return;
}
return new VerifierCtor(this.channel, this.client, userId!, deviceId!, startEvent, this);
}
private wasSentByOwnUser(event?: MatrixEvent): boolean {
return event?.getSender() === this.client.getUserId();
}
// only for .request, .ready or .start
private wasSentByOwnDevice(event?: MatrixEvent): boolean {
if (!this.wasSentByOwnUser(event)) {
return false;
}
const content = event!.getContent();
if (!content || content.from_device !== this.client.getDeviceId()) {
return false;
}
return true;
}
public onVerifierCancelled(): void {
this._cancelled = true;
// move to cancelled phase
const newTransitions = this.applyPhaseTransitions();
if (newTransitions.length) {
this.setPhase(newTransitions[newTransitions.length - 1].phase);
}
}
public onVerifierFinished(): void {
this.channel.send(EventType.KeyVerificationDone, {});
this.verifierHasFinished = true;
// move to .done phase
const newTransitions = this.applyPhaseTransitions();
if (newTransitions.length) {
this.setPhase(newTransitions[newTransitions.length - 1].phase);
}
}
public getEventFromOtherParty(type: string): MatrixEvent | undefined {
return this.eventsByThem.get(type);
}
}

View File

@ -54,8 +54,6 @@ import { MatrixError } from "./http-api/errors.ts";
import { User } from "./models/user.ts";
import { type Room } from "./models/room.ts";
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { type DeviceInfo } from "./crypto/deviceinfo.ts";
import { type IOlmDevice } from "./crypto/algorithms/megolm.ts";
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
import { type EmptyObject, TypedEventEmitter } from "./matrix.ts";
@ -64,6 +62,17 @@ interface IStateEventRequest {
stateKey?: string;
}
export interface OlmDevice {
/**
* The user ID of the device owner.
*/
userId: string;
/**
* The device ID of the device.
*/
deviceId: string;
}
export interface ICapabilities {
/**
* Event types that this client expects to send.
@ -128,6 +137,7 @@ export enum RoomWidgetClientEvent {
PendingEventsChanged = "PendingEvent.pendingEventsChanged",
}
export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: () => void };
/**
* A MatrixClient that routes its requests through the widget API instead of the
* real CS API.
@ -467,13 +477,10 @@ export class RoomWidgetClient extends MatrixClient {
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
}
public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
// map: user Id → device Id → payload
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
for (const {
userId,
deviceInfo: { deviceId },
} of userDeviceInfoArr) {
for (const { userId, deviceId } of userDeviceInfoArr) {
contentMap.getOrCreate(userId).set(deviceId, payload);
}

View File

@ -85,7 +85,6 @@ export * from "./models/related-relations.ts";
export type { RoomSummary } from "./client.ts";
export * as ContentHelpers from "./content-helpers.ts";
export * as SecretStorage from "./secret-storage.ts";
export type { ICryptoCallbacks } from "./crypto/index.ts"; // used to be located here
export { createNewMatrixCall, CallEvent } from "./webrtc/call.ts";
export type { MatrixCall } from "./webrtc/call.ts";
export {
@ -97,10 +96,6 @@ export {
GroupCallStatsReportEvent,
} from "./webrtc/groupCall.ts";
export {
/** @deprecated Use {@link Crypto.CryptoEvent} instead */
CryptoEvent,
} from "./crypto/index.ts";
export { SyncState, SetPresence } from "./sync.ts";
export type { ISyncStateData as SyncStateData } from "./sync.ts";
export { SlidingSyncEvent } from "./sliding-sync.ts";
@ -115,9 +110,6 @@ export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth.ts";
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces.ts";
export { LocationAssetType } from "./@types/location.ts";
/** @deprecated Backwards-compatibility re-export. Import from `crypto-api` directly. */
export * as Crypto from "./crypto-api/index.ts";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
/**

View File

@ -27,7 +27,7 @@ export type DeviceMap = Map<string, Map<string, Device>>;
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
/**
* Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}.
* Information on a user's device, as returned by {@link crypto-api!CryptoApi.getUserDeviceInfo}.
*/
export class Device {
/** id of the device */

View File

@ -23,7 +23,6 @@ import { type ExtensibleEvent, ExtensibleEvents, type Optional } from "matrix-ev
import type { IEventDecryptionResult } from "../@types/crypto.ts";
import { logger } from "../logger.ts";
import { type VerificationRequest } from "../crypto/verification/request/VerificationRequest.ts";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
EventType,
@ -33,7 +32,6 @@ import {
UNSIGNED_THREAD_ID_FIELD,
UNSIGNED_MEMBERSHIP_FIELD,
} from "../@types/event.ts";
import { type Crypto } from "../crypto/index.ts";
import { deepSortedObjectEntries, internaliseString } from "../utils.ts";
import { type RoomMember } from "./room-member.ts";
import { type Thread, THREAD_RELATION_TYPE, ThreadEvent, type ThreadEventHandlerMap } from "./thread.ts";
@ -407,15 +405,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*/
public forwardLooking = true;
/**
* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event,
* `Crypto` will set this the `VerificationRequest` for the event
* so it can be easily accessed from the timeline.
*
* @deprecated Not used by the rust crypto implementation.
*/
public verificationRequest?: VerificationRequest;
private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>;
/**
@ -893,30 +882,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.decryptionPromise;
}
/**
* Cancel any room key request for this event and resend another.
*
* @param crypto - crypto module
* @param userId - the user who received this event
*
* @returns a promise that resolves when the request is queued
*
* @deprecated Not used by the rust crypto implementation.
*/
public cancelAndResendKeyRequest(crypto: Crypto, userId: string): Promise<void> {
const wireContent = this.getWireContent();
return crypto.requestRoomKey(
{
algorithm: wireContent.algorithm,
room_id: this.getRoomId()!,
session_id: wireContent.session_id,
sender_key: wireContent.sender_key,
},
this.getKeyRequestRecipients(userId),
true,
);
}
/**
* Calculate the recipients for keyshare requests.
*
@ -1114,7 +1079,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* signing the public curve25519 key with the ed25519 key.
*
* In general, applications should not use this method directly, but should
* instead use {@link Crypto.CryptoApi#getEncryptionInfoForEvent}.
* instead use {@link crypto-api!CryptoApi#getEncryptionInfoForEvent}.
*/
public getClaimedEd25519Key(): string | null {
return this.claimedEd25519Key;
@ -1726,13 +1691,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
};
}
/**
* @deprecated Not used by the rust crypto implementation.
*/
public setVerificationRequest(request: VerificationRequest): void {
this.verificationRequest = request;
}
public setTxnId(txnId: string): void {
this.txnId = txnId;
}

View File

@ -118,7 +118,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
/**
* Determine if a key backup can be trusted.
*
* @param info - key backup info dict from {@link MatrixClient#getKeyBackupVersion}.
* @param info - key backup info dict from {@link CryptoApi.getKeyBackupInfo}.
*/
public async isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo> {
const signatureVerification: SignatureVerification = await this.olmMachine.verifyBackup(info);

View File

@ -20,7 +20,6 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
import { KnownMembership } from "../@types/membership.ts";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
import type { IEncryptedEventInfo } from "../crypto/api.ts";
import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts";
import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
@ -285,64 +284,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
return await this.eventDecryptor.attemptEventDecryption(event, this.deviceIsolationMode);
}
/**
* Implementation of (deprecated) {@link MatrixClient#getEventEncryptionInfo}.
*
* @param event - event to inspect
*/
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
const ret: Partial<IEncryptedEventInfo> = {};
ret.senderKey = event.getSenderKey() ?? undefined;
ret.algorithm = event.getWireContent().algorithm;
if (!ret.senderKey || !ret.algorithm) {
ret.encrypted = false;
return ret as IEncryptedEventInfo;
}
ret.encrypted = true;
ret.authenticated = true;
ret.mismatchedSender = true;
return ret as IEncryptedEventInfo;
}
/**
* Implementation of {@link CryptoBackend#checkUserTrust}.
*
* Stub for backwards compatibility.
*
*/
public checkUserTrust(userId: string): UserVerificationStatus {
return new UserVerificationStatus(false, false, false);
}
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
public getStoredCrossSigningForUser(userId: string): null {
// TODO
return null;
}
/**
* This function is unneeded for the rust-crypto.
* The cross signing key import and the device verification are done in {@link CryptoApi#bootstrapCrossSigning}
*
* The function is stub to keep the compatibility with the old crypto.
* More information: https://github.com/vector-im/element-web/issues/25648
*
* Implementation of {@link CryptoBackend#checkOwnCrossSigningTrust}
*/
public async checkOwnCrossSigningTrust(): Promise<void> {
return;
}
/**
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
*/

View File

@ -65,7 +65,6 @@ import { BeaconEvent } from "./models/beacon.ts";
import { type IEventsResponse } from "./@types/requests.ts";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
import { Feature, ServerSupport } from "./feature.ts";
import { type Crypto } from "./crypto/index.ts";
import { KnownMembership } from "./@types/membership.ts";
const DEBUG = true;
@ -122,13 +121,6 @@ function debuglog(...params: any[]): void {
* Options passed into the constructor of SyncApi by MatrixClient
*/
export interface SyncApiOptions {
/**
* Crypto manager
*
* @deprecated in favour of cryptoCallbacks
*/
crypto?: Crypto;
/**
* If crypto is enabled on our client, callbacks into the crypto module
*/
@ -648,9 +640,6 @@ export class SyncApi {
}
this.opts.filter.setLazyLoadMembers(true);
}
if (this.opts.lazyLoadMembers) {
this.syncOpts.crypto?.enableLazyLoading();
}
};
private storeClientOptions = async (): Promise<void> => {
@ -886,12 +875,6 @@ export class SyncApi {
catchingUp: this.catchingUp,
};
if (this.syncOpts.crypto) {
// tell the crypto module we're about to process a sync
// response
await this.syncOpts.crypto.onSyncWillProcess(syncEventData);
}
try {
await this.processSyncResponse(syncEventData, data);
} catch (e) {
@ -926,15 +909,6 @@ export class SyncApi {
this.updateSyncState(SyncState.Syncing, syncEventData);
if (this.client.store.wantsSave()) {
// We always save the device list (if it's dirty) before saving the sync data:
// this means we know the saved device list data is at least as fresh as the
// stored sync data which means we don't have to worry that we may have missed
// device changes. We can also skip the delay since we're not calling this very
// frequently (and we don't really want to delay the sync for it).
if (this.syncOpts.crypto) {
await this.syncOpts.crypto.saveDeviceList(0);
}
// tell databases that everything is now in a consistent state and can be saved.
await this.client.store.save();
}
@ -1254,27 +1228,6 @@ export class SyncApi {
await this.injectRoomEvents(room, stateEvents, undefined);
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender();
const crypto = client.crypto;
if (crypto) {
const parkedHistory = await crypto.cryptoStore.takeParkedSharedHistory(room.roomId);
for (const parked of parkedHistory) {
if (parked.senderId === inviter) {
await crypto.olmDevice.addInboundGroupSession(
room.roomId,
parked.senderKey,
parked.forwardingCurve25519KeyChain,
parked.sessionId,
parked.sessionKey,
parked.keysClaimed,
true,
{ sharedHistory: true, untrusted: true },
);
}
}
}
if (inviteObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);

View File

@ -48,7 +48,6 @@ import {
import { CallFeed } from "./callFeed.ts";
import { type MatrixClient } from "../client.ts";
import { EventEmitterEvents, TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { DeviceInfo } from "../crypto/deviceinfo.ts";
import { GroupCallUnknownDeviceError } from "./groupCall.ts";
import { type IScreensharingOpts } from "./mediaHandler.ts";
import { MatrixError } from "../http-api/index.ts";
@ -426,7 +425,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private callStartTime?: number;
private opponentDeviceId?: string;
private opponentDeviceInfo?: DeviceInfo;
private hasOpponentDeviceInfo?: boolean;
private opponentSessionId?: string;
public groupCallId?: string;
@ -631,23 +630,18 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
if (!this.client.getUseE2eForGroupCall()) return;
// It's possible to want E2EE and yet not have the means to manage E2EE
// ourselves (for example if the client is a RoomWidgetClient)
if (!this.client.isCryptoEnabled()) {
if (!this.client.getCrypto()) {
// All we know is the device ID
this.opponentDeviceInfo = new DeviceInfo(this.opponentDeviceId);
this.hasOpponentDeviceInfo = true;
return;
}
// if we've got to this point, we do want to init crypto, so throw if we can't
if (!this.client.crypto) throw new Error("Crypto is not initialised.");
const userId = this.invitee || this.getOpponentMember()?.userId;
if (!userId) throw new Error("Couldn't find opponent user ID to init crypto");
const deviceInfoMap = await this.client.crypto.deviceList.downloadKeys([userId], false);
this.opponentDeviceInfo = deviceInfoMap.get(userId)?.get(this.opponentDeviceId);
if (this.opponentDeviceInfo === undefined) {
throw new GroupCallUnknownDeviceError(userId);
}
// Here we were calling `MatrixClient.crypto.deviceList.downloadKeys` which is not supported by the rust cryptography.
this.hasOpponentDeviceInfo = false;
throw new GroupCallUnknownDeviceError(userId);
}
/**
@ -2511,23 +2505,14 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
const userId = this.invitee || this.getOpponentMember()!.userId;
if (this.client.getUseE2eForGroupCall()) {
if (!this.opponentDeviceInfo) {
if (!this.hasOpponentDeviceInfo) {
logger.warn(`Call ${this.callId} sendVoipEvent() failed: we do not have opponentDeviceInfo`);
return;
}
await this.client.encryptAndSendToDevices(
[
{
userId,
deviceInfo: this.opponentDeviceInfo,
},
],
{
type: eventType,
content,
},
);
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevices.
// However due to the switch to Rust cryptography we need to migrate to the new encryptToDeviceMessages API.
throw new Error("Unimplemented");
} else {
await this.client.sendToDevice(
eventType,