Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Element-R: Store cross signing keys in secret storage #3498

52 changes: 5 additions & 47 deletions spec/integ/crypto/cross-signing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";

import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
import { createClient, MatrixClient, IAuthDict, UIAuthCallback } from "../../../src";
import { createClient, MatrixClient } from "../../../src";
import { bootstrapCrossSigning, mockSetupCrossSigningRequests } from "../../test-utils/cross-signing";

afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
Expand Down Expand Up @@ -61,56 +62,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
fetchMock.mockReset();
});

/**
* Mock the requests needed to set up cross signing
*
* Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
*/
function mockSetupCrossSigningRequests(): void {
// 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(
// legacy crypto uses /unstable/; /v3/ is correct
{
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
name: "upload-keys",
},
{},
);
}

/**
* Create cross-signing keys, publish the keys
* Mock and bootstrap all the required steps
*
* @param authDict - The parameters to as the `auth` dict in the key upload request.
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
*/
async function bootstrapCrossSigning(authDict: IAuthDict): Promise<void> {
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
await makeRequest(authDict);
};

// now bootstrap cross signing, and check it resolves successfully
await aliceClient.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: uiaCallback,
});
}

describe("bootstrapCrossSigning (before initialsync completes)", () => {
it("publishes keys if none were yet published", async () => {
mockSetupCrossSigningRequests();

// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
await bootstrapCrossSigning(aliceClient, authDict);

// check the cross-signing keys upload
expect(fetchMock.called("upload-keys")).toBeTruthy();
Expand Down Expand Up @@ -156,7 +114,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s

// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
await bootstrapCrossSigning(aliceClient, authDict);

const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();

Expand All @@ -180,7 +138,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s

it("should return true after bootstrapping cross-signing", async () => {
mockSetupCrossSigningRequests();
await bootstrapCrossSigning({ type: "test" });
await bootstrapCrossSigning(aliceClient, { type: "test" });

const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();

Expand Down
98 changes: 84 additions & 14 deletions spec/integ/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ import { escapeRegExp } from "../../../src/utils";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
import { flushPromises } from "../../test-utils/flushPromises";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { mockSetupCrossSigningRequests } from "../../test-utils/cross-signing";
import { CryptoCallbacks } from "../../../src/crypto-api";

const ROOM_ID = "!room:id";

Expand Down Expand Up @@ -530,6 +532,27 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
};
}

/**
* Create the {@link CryptoCallbacks}
*/
function createCryptoCallbacks(): CryptoCallbacks {
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
let cachedKey: { keyId: string; key: Uint8Array };
const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
cachedKey = {
keyId,
key,
};
};

const getSecretStorageKey = () => Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]);

return {
cacheSecretStorageKey,
getSecretStorageKey,
};
}

beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
Expand All @@ -541,6 +564,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
userId: "@alice:localhost",
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
cryptoCallbacks: createCryptoCallbacks(),
});

/* set up listeners for /keys/upload and /sync */
Expand Down Expand Up @@ -2183,16 +2207,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});

/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type`
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)`
* Resolved when a key is uploaded (ie in `body.content.key`)
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
function awaitKeyStoredInAccountData(): Promise<string> {
function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
return new Promise((resolve) => {
// This url is called multiple times during the secret storage bootstrap process
// When we received the newly generated key, we return it
fetchMock.put(
"express:/_matrix/client/r0/user/:userId/account_data/:type",
"express:/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)",
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);

Expand All @@ -2207,6 +2231,25 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
}

/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/m.cross_signing.master`
* Resolved when the cross signing master key is uploaded
florianduros marked this conversation as resolved.
Show resolved Hide resolved
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
function awaitCrossSigningMasterKeyUpload(): Promise<Record<string, {}>> {
return new Promise((resolve) => {
// Called when the cross signing key master key is uploaded
fetchMock.put(
"express:/_matrix/client/r0/user/:userId/account_data/m.cross_signing.master",
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
resolve(content.encrypted);
return {};
},
);
});
}

/**
* Send in the sync response the provided `secretStorageKey` into the account_data field
* The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events
Expand Down Expand Up @@ -2245,20 +2288,22 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await startClientAndAwaitFirstSync();
});

newBackendOnly("should do no nothing if createSecretStorageKey is not set", async () => {
await aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true });

// No key was created
expect(createSecretStorageKey).toHaveBeenCalledTimes(0);
});
newBackendOnly(
"should throw an error if we are unable to create a key because createSecretStorageKey is not set",
async () => {
await expect(
aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }),
).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set");
},
);

newBackendOnly("should create a new key", async () => {
const bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });

// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitKeyStoredInAccountData();
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();

// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
Expand All @@ -2279,7 +2324,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });

// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitKeyStoredInAccountData();
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();

// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
Expand All @@ -2303,7 +2348,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });

// Wait for the key to be uploaded in the account data
let secretStorageKey = await awaitKeyStoredInAccountData();
let secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();

// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
Expand All @@ -2317,7 +2362,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });

// Wait for the key to be uploaded in the account data
secretStorageKey = await awaitKeyStoredInAccountData();
secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();

// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
Expand All @@ -2329,5 +2374,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(createSecretStorageKey).toHaveBeenCalledTimes(2);
},
);

newBackendOnly("should upload cross signing master key", async () => {
mockSetupCrossSigningRequests();

await aliceClient.getCrypto()?.bootstrapCrossSigning({});
florianduros marked this conversation as resolved.
Show resolved Hide resolved

const bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
florianduros marked this conversation as resolved.
Show resolved Hide resolved

// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();

// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);

// Wait for the cross signing key to be uploaded
const crossSigningKey = await awaitCrossSigningMasterKeyUpload();

// Finally, wait for bootstrapSecretStorage to finished
await bootstrapPromise;

// Expect the cross signing master key to be uploaded and to be encrypted with `secretStorageKey`
expect(crossSigningKey[secretStorageKey]).toBeDefined();
});
});
});
62 changes: 62 additions & 0 deletions spec/test-utils/cross-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
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 { IAuthDict, MatrixClient, UIAuthCallback } from "../../src";

/**
* Mock the requests needed to set up cross signing
*
* Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
*/
export function mockSetupCrossSigningRequests(): void {
// 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(
// legacy crypto uses /unstable/; /v3/ is correct
{
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
name: "upload-keys",
},
{},
);
}
florianduros marked this conversation as resolved.
Show resolved Hide resolved

/**
* Create cross-signing keys and publish the keys
*
* @param matrixClient - The matrixClient to bootstrap.
* @param authDict - The parameters to as the `auth` dict in the key upload request.
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
*/
export async function bootstrapCrossSigning(matrixClient: MatrixClient, authDict: IAuthDict): Promise<void> {
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
await makeRequest(authDict);
};
florianduros marked this conversation as resolved.
Show resolved Hide resolved

// now bootstrap cross signing, and check it resolves successfully
await matrixClient.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: uiaCallback,
});
}
3 changes: 3 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ export interface ICreateClientOpts {
*/
useE2eForGroupCall?: boolean;

/**
* Crypto callbacks provided by the application
*/
cryptoCallbacks?: ICryptoCallbacks;

/**
Expand Down
9 changes: 7 additions & 2 deletions src/crypto-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,17 @@ export interface CryptoApi {
isSecretStorageReady(): Promise<boolean>;

/**
* Bootstrap the secret storage by creating a new secret storage key and store it in the secret storage.
* Bootstrap the secret storage by creating a new secret storage key, add it in the secret storage and
* store the cross signing keys in the secret storage.
*
* - Do nothing if an AES key is already stored in the secret storage and `setupNewKeyBackup` is not set;
* - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`.
* Only if `setupNewSecretStorage` is set or if there is no AES key in the secret storage
* - Store this key in the secret storage and set it as the default key.
* - Call `cryptoCallbacks.cacheSecretStorageKey` if provided.
* - Store the cross signing keys in the secret storage if
* - the cross signing is ready
* - a new key was created during the previous step
* - or the secret storage already contains the cross signing keys
*
* @param opts - Options object.
*/
Expand Down
Loading