diff --git a/spec/integ/cross-signing.spec.ts b/spec/integ/cross-signing.spec.ts new file mode 100644 index 000000000..0b2068309 --- /dev/null +++ b/spec/integ/cross-signing.spec.ts @@ -0,0 +1,117 @@ +/* +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 fetchMock from "fetch-mock-jest"; +import "fake-indexeddb/auto"; +import { IDBFactory } from "fake-indexeddb"; + +import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils"; +import { createClient, MatrixClient, UIAuthCallback } from "../../src"; + +afterEach(() => { + // reset fake-indexeddb after each test, to make sure we don't leak connections + // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state + // eslint-disable-next-line no-global-assign + indexedDB = new IDBFactory(); +}); + +const TEST_USER_ID = "@alice:localhost"; +const TEST_DEVICE_ID = "xzcvb"; + +/** + * Integration tests for cross-signing functionality. + * + * 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) => { + // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the + // Rust backend. Once we have full support in the rust sdk, it will go away. + const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; + + let aliceClient: MatrixClient; + + beforeEach(async () => { + // anything that we don't have a specific matcher for silently returns a 404 + fetchMock.catch(404); + fetchMock.config.warnOnFallback = false; + + const homeserverUrl = "https://alice-server.com"; + aliceClient = createClient({ + baseUrl: homeserverUrl, + userId: TEST_USER_ID, + accessToken: "akjgkrgjs", + deviceId: TEST_DEVICE_ID, + }); + + await initCrypto(aliceClient); + }); + + afterEach(async () => { + await aliceClient.stopClient(); + fetchMock.mockReset(); + }); + + describe("bootstrapCrossSigning (before initialsync completes)", () => { + oldBackendOnly("publishes keys if none were yet published", async () => { + // have account_data requests return an empty object + fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {}); + + // we expect a request to upload signatures for our device ... + fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {}); + + // ... and one to upload the cross-signing keys (with UIA) + fetchMock.post( + { url: "path:/_matrix/client/unstable/keys/device_signing/upload", name: "upload-keys" }, + {}, + ); + + // provide a UIA callback, so that the cross-signing keys are uploaded + const authDict = { type: "test" }; + const uiaCallback: UIAuthCallback = async (makeRequest) => { + await makeRequest(authDict); + }; + + // now bootstrap cross signing, and check it resolves successfully + await aliceClient.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: uiaCallback, + }); + + // check the cross-signing keys upload + expect(fetchMock.called("upload-keys")).toBeTruthy(); + const [, keysOpts] = fetchMock.lastCall("upload-keys")!; + const keysBody = JSON.parse(keysOpts!.body as string); + expect(keysBody.auth).toEqual(authDict); // check uia dict was passed + // there should be a key of each type + // master key is signed by the device + expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`); + const masterKeyId = Object.keys(keysBody.master_key.keys)[0]; + // ssk and usk are signed by the master key + expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`); + expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`); + const sskId = Object.keys(keysBody.self_signing_key.keys)[0]; + + // check the publish call + expect(fetchMock.called("upload-sigs")).toBeTruthy(); + const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!; + const body = JSON.parse(sigsOpts!.body as string); + // there should be a signature for our device, by our self-signing key. + expect(body).toHaveProperty( + `[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`, + ); + }); + }); +}); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 3b5231d55..e77a3d5e8 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -2758,6 +2758,10 @@ describe("MatrixClient", function () { 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"); }); @@ -2769,6 +2773,7 @@ describe("MatrixClient", function () { beforeEach(() => { mockCryptoBackend = { isCrossSigningReady: jest.fn(), + bootstrapCrossSigning: jest.fn(), isSecretStorageReady: jest.fn(), stop: jest.fn().mockResolvedValue(undefined), } as unknown as Mocked; @@ -2782,6 +2787,14 @@ describe("MatrixClient", function () { 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"; diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 397b4df03..1297003b7 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -98,6 +98,11 @@ describe("RustCrypto", () => { await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false); }); + it("bootstrapCrossSigning", async () => { + const rustCrypto = await makeTestRustCrypto(); + await rustCrypto.bootstrapCrossSigning({}); + }); + it("isSecretStorageReady", async () => { const rustCrypto = await makeTestRustCrypto(); await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false); diff --git a/src/client.ts b/src/client.ts index 216f34d6b..a037a5e4f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2747,15 +2747,15 @@ export class MatrixClient extends TypedEventEmitter { - if (!this.crypto) { + if (!this.cryptoBackend) { throw new Error("End-to-end encryption disabled"); } - return this.crypto.bootstrapCrossSigning(opts); + return this.cryptoBackend.bootstrapCrossSigning(opts); } /** diff --git a/src/crypto-api.ts b/src/crypto-api.ts index e303d0583..338bfee46 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -137,6 +137,22 @@ export interface CryptoApi { */ isCrossSigningReady(): Promise; + /** + * Bootstrap cross-signing by creating keys if needed. + * + * If everything is already set up, then no changes are made, so this is safe to run to ensure + * cross-signing is ready for use. + * + * This function: + * - creates new cross-signing keys if they are not found locally cached nor in + * secret storage (if it has been set up) + * - publishes the public keys to the server if they are not already published + * - stores the private keys in secret storage if secret storage is set up. + * + * @param opts - options object + */ + bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise; + /** * Checks whether secret storage: * - is enabled on this account diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 3f7b6f52e..d92b7a9ca 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -30,7 +30,7 @@ import { RoomEncryptor } from "./RoomEncryptor"; import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; import { KeyClaimManager } from "./KeyClaimManager"; import { MapWithDefault } from "../utils"; -import { DeviceVerificationStatus } from "../crypto-api"; +import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client"; import { Device, DeviceMap } from "../models/device"; @@ -324,6 +324,13 @@ export class RustCrypto implements CryptoBackend { return false; } + /** + * Implementation of {@link CryptoApi#boostrapCrossSigning} + */ + public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise { + logger.log("Cross-signing ready"); + } + /** * Implementation of {@link CryptoApi#isSecretStorageReady} */