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

ElementR: Add CryptoApi#bootstrapSecretStorage #3483

Merged
merged 21 commits into from
Jun 20, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2ad15eb
Add WIP bootstrapSecretStorage
florianduros Jun 16, 2023
ebe7bc2
Add new test if `createSecretStorageKey` is not set
florianduros Jun 16, 2023
900745f
Remove old comments
florianduros Jun 16, 2023
9ffc9d5
Add docs for `crypto-api.bootstrapSecretStorage`
florianduros Jun 16, 2023
3be57f4
Remove default parameter for `createSecretStorageKey`
florianduros Jun 16, 2023
e2ffbdb
Move `bootstrapSecretStorage` next to `isSecretStorageReady`
florianduros Jun 19, 2023
2ca8acf
Deprecate `bootstrapSecretStorage` in `MatrixClient`
florianduros Jun 19, 2023
9cc7eda
Update documentations
florianduros Jun 19, 2023
d744d6b
Raise error if missing `keyInfo`
florianduros Jun 19, 2023
18d629a
Update behavior around `setupNewSecretStorage`
florianduros Jun 19, 2023
5c24168
Move `ICreateSecretStorageOpts` to `rust-crypto`
florianduros Jun 19, 2023
4ce3c3c
Move `ICryptoCallbacks` to `rust-crypto`
florianduros Jun 19, 2023
5c72ebc
Update `bootstrapSecretStorage` documentation
florianduros Jun 19, 2023
ff7f2b5
Add partial `CryptoCallbacks` documentation
florianduros Jun 19, 2023
f01c20c
Fix typo
florianduros Jun 19, 2023
397a3f9
Merge branch 'develop' into florianduros/element-r/bootstrapSecretSto…
florianduros Jun 19, 2023
9a59e35
Review changes
florianduros Jun 20, 2023
be55709
Merge remote-tracking branch 'origin/florianduros/element-r/bootstrap…
florianduros Jun 20, 2023
2f40b87
Review changes
florianduros Jun 20, 2023
cbb98be
Merge branch 'develop' into florianduros/element-r/bootstrapSecretSto…
florianduros Jun 20, 2023
7cb87e5
Merge branch 'develop' into florianduros/element-r/bootstrapSecretSto…
florianduros Jun 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ module.exports = {
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"],
},
],
},
Expand Down
161 changes: 161 additions & 0 deletions spec/integ/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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";

const ROOM_ID = "!room:id";

Expand Down Expand Up @@ -402,6 +403,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// 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 !== "rust-sdk" ? test.skip : test;

const Olm = global.Olm;

Expand Down Expand Up @@ -2169,4 +2171,163 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
);
});
});

describe("bootstrapSecretStorage", () => {
/**
* Create a fake secret storage key
* Async because `bootstrapSecretStorage` expect an async method
*/
const createSecretStorageKey = jest.fn().mockResolvedValue({
keyInfo: {}, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
});

/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type`
* 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> {
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",
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);

if (content.key) {
resolve(content.key);
}

return {};
},
{ overwriteRoutes: true },
);
});
}

/**
* 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
* https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync
* @param secretStorageKey
*/
function sendSyncResponse(secretStorageKey: string) {
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: [
{
type: "m.secret_storage.default_key",
content: {
key: secretStorageKey,
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
},
},
// Needed for secretStorage.getKey or secretStorage.hasKey
{
type: `m.secret_storage.key.${secretStorageKey}`,
content: {
key: secretStorageKey,
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
},
},
],
},
});
}

beforeEach(async () => {
createSecretStorageKey.mockClear();

expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
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 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();

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

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

const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId();
// Check that the uploaded key in stored in the secret storage
expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy();
// Check that the uploaded key is the default key
expect(defaultKeyId).toBe(secretStorageKey);
});

newBackendOnly(
"should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set",
async () => {
const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });

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

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

// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;

// Call again bootstrapSecretStorage
await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });

// createSecretStorageKey should be called only on the first run of bootstrapSecretStorage
expect(createSecretStorageKey).toHaveBeenCalledTimes(1);
},
);

newBackendOnly(
"should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage",
async () => {
let bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });

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

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

// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;

// Call again bootstrapSecretStorage
bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });

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

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

// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;

// createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call
expect(createSecretStorageKey).toHaveBeenCalledTimes(2);
},
);
});
});
7 changes: 5 additions & 2 deletions spec/unit/rust-crypto/rust-crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
import { ImportRoomKeysOpts } from "../../../src/crypto-api";
import { CryptoCallbacks, ImportRoomKeysOpts } from "../../../src/crypto-api";

afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
Expand Down Expand Up @@ -211,6 +211,7 @@ describe("RustCrypto", () => {
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
});
Expand Down Expand Up @@ -333,6 +334,7 @@ describe("RustCrypto", () => {
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
});

Expand Down Expand Up @@ -400,6 +402,7 @@ async function makeTestRustCrypto(
userId: string = TEST_USER,
deviceId: string = TEST_DEVICE_ID,
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto(http, userId, deviceId, secretStorage);
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks);
}
9 changes: 8 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2223,7 +2223,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
const rustCrypto = await RustCrypto.initRustCrypto(
this.http,
userId,
deviceId,
this.secretStorage,
this.cryptoCallbacks,
);
this.cryptoBackend = rustCrypto;

// attach the event listeners needed by RustCrypto
Expand Down Expand Up @@ -2874,6 +2880,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* - migrates Secure Secret Storage to use the latest algorithm, if an outdated
* algorithm is found
*
* @deprecated Use {@link CryptoApi#bootstrapSecretStorage}.
*/
public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
if (!this.crypto) {
Expand Down
81 changes: 80 additions & 1 deletion src/crypto-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
import { AddSecretStorageKeyOpts } from "./secret-storage";
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { VerificationRequest } from "./crypto-api/verification";
import { KeyBackupInfo } from "./rust-crypto/keybackup";

/** Types of cross-signing key */
export enum CrossSigningKey {
Expand All @@ -39,6 +40,44 @@ export interface GeneratedSecretStorageKey {
encodedPrivateKey?: string;
}

/**
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
*/
export interface CreateSecretStorageOpts {
florianduros marked this conversation as resolved.
Show resolved Hide resolved
/**
* Function called to await a secret storage key creation flow.
* @returns Promise resolving to an object with public key metadata, encoded private
* recovery key which should be disposed of after displaying to the user,
* and raw private key to avoid round tripping if needed.
*/
createSecretStorageKey?: () => Promise<GeneratedSecretStorageKey>;

/**
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
*/
keyBackupInfo?: KeyBackupInfo;

/**
* If true, a new key backup version will be
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
* is supplied.
*/
setupNewKeyBackup?: boolean;

/**
* Reset even if keys already exist.
*/
setupNewSecretStorage?: boolean;

/**
* Function called to get the user's
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
* containing the key, or rejects if the key cannot be obtained.
*/
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}

/**
* Public interface to the cryptography parts of the js-sdk
*
Expand Down Expand Up @@ -229,6 +268,18 @@ export interface CryptoApi {
*/
createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey>;

/**
* Bootstrap the secret storage by creating a new secret storage key and store it 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`.
* - Store this key in the secret storage and set it as the default key.
* - Call `cryptoCallbacks.cacheSecretStorageKey` if provided.
*
* @param opts - Options object.
*/
bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise<void>;
florianduros marked this conversation as resolved.
Show resolved Hide resolved

///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Device/User verification
Expand Down Expand Up @@ -396,3 +447,31 @@ export interface CrossSigningStatus {
userSigningKey: boolean;
};
}

/**
* Crypto callbacks provided by the application
*/
export interface CryptoCallbacks extends SecretStorageCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
/**
* Called by {@link CryptoApi#bootstrapSecretStorage}
* @param keyId - secret storage key id
* @param keyInfo - secret storage key info
* @param key - private key to store
*/
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
onSecretRequested?: (
userId: string,
deviceId: string,
requestId: string,
secretName: string,
deviceTrust: DeviceVerificationStatus,
) => Promise<string | undefined>;
getDehydrationKey?: (
keyInfo: SecretStorageKeyDescription,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
Loading
Loading