1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-25 05:23:13 +03:00

Implement isSecretStorageReady in rust (#3730)

* Implement isSecretStorageReady in rust

* refactor extract common code to check 4S access

* fix incomplete mocks

* code review

* Remove keyId para from secretStorageCanAccessSecrets

* use map instead of array

* code review
This commit is contained in:
Valere
2023-09-21 18:55:41 +02:00
committed by GitHub
parent f134d6db01
commit 4947a0cb64
6 changed files with 231 additions and 80 deletions

View File

@@ -39,6 +39,7 @@ import { TestClient } from "../../TestClient";
import { logger } from "../../../src/logger";
import {
Category,
ClientEvent,
createClient,
CryptoEvent,
IClaimOTKsResult,
@@ -68,7 +69,7 @@ import {
mockSetupCrossSigningRequests,
mockSetupMegolmBackupRequests,
} from "../../test-utils/mockEndpoints";
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { CrossSigningKey, CryptoCallbacks, KeyBackupInfo } from "../../../src/crypto-api";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { DecryptionError } from "../../../src/crypto/algorithms";
@@ -2283,6 +2284,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
describe("Secret Storage and Key Backup", () => {
/**
* The account data events to be returned by the sync.
* Will be updated when fecthMock intercepts calls to PUT `/_matrix/client/v3/user/:userId/account_data/`.
* Will be used by `sendSyncResponseWithUpdatedAccountData`
*/
let accountDataEvents: Map<String, any>;
/**
* Create a fake secret storage key
* Async because `bootstrapSecretStorage` expect an async method
@@ -2294,11 +2302,35 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
beforeEach(async () => {
createSecretStorageKey.mockClear();
accountDataEvents = new Map();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
});
function mockGetAccountData() {
fetchMock.get(
`path:/_matrix/client/v3/user/:userId/account_data/:type`,
(url) => {
const type = url.split("/").pop();
const existing = accountDataEvents.get(type!);
if (existing) {
// return it
return {
status: 200,
body: existing.content,
};
} else {
// 404
return {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
};
}
},
{ overwriteRoutes: true },
);
}
/**
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
* Resolved when the cross signing key is uploaded
@@ -2311,6 +2343,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
const type = url.split("/").pop();
// update account data for sync response
accountDataEvents.set(type!, content);
resolve(content.encrypted);
return {};
},
@@ -2319,32 +2354,24 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
}
/**
* 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
* Send in the sync response the current account data events, as stored by `accountDataEvents`.
*/
function sendSyncResponse(secretStorageKey: string) {
function sendSyncResponseWithUpdatedAccountData() {
try {
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: [
{
type: "m.secret_storage.default_key",
content: {
key: secretStorageKey,
},
},
// Needed for secretStorage.getKey or secretStorage.hasKey
{
type: `m.secret_storage.key.${secretStorageKey}`,
content: {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
},
},
],
events: Array.from(accountDataEvents, ([type, content]) => ({
type: type,
content: content,
})),
},
});
} catch (err) {
// Might fail with "Cannot queue more than one /sync response" if called too often.
// It's ok if it fails here, the sync response is cumulative and will contain
// the latest account data.
}
}
/**
@@ -2359,10 +2386,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
fetchMock.put(
"express:/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)",
(url: string, options: RequestInit) => {
const type = url.split("/").pop();
const content = JSON.parse(options.body as string);
// update account data for sync response
accountDataEvents.set(type!, content);
if (content.key) {
resolve(content.key);
}
sendSyncResponseWithUpdatedAccountData();
return {};
},
{ overwriteRoutes: true },
@@ -2377,6 +2410,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
`express:/_matrix/client/v3/user/:userId/account_data/m.megolm_backup.v1`,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
// update account data for sync response
accountDataEvents.set("m.megolm_backup.v1", content);
resolve(content.encrypted);
return {};
},
@@ -2385,6 +2420,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
}
function awaitAccountDataUpdate(type: string): Promise<void> {
return new Promise((resolve) => {
aliceClient.on(ClientEvent.AccountData, (ev: MatrixEvent): void => {
if (ev.getType() === type) {
resolve();
}
});
});
}
/**
* Add all mocks needed to setup cross-signing, key backup, 4S and then
* configure the account to have recovery.
@@ -2422,32 +2467,41 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
// 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);
await awaitSecretStorageKeyStoredInAccountData();
// Wait for the cross signing keys to be uploaded
await Promise.all(setupPromises);
// wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
// Finally ensure backup is working
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
await backupStatusUpdate;
}
describe("bootstrapSecretStorage", () => {
// Doesn't work with legacy crypto, which will try to bootstrap even without private key, which is buggy.
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");
expect(await aliceClient.getCrypto()!.isSecretStorageReady()).toStrictEqual(false);
},
);
it("should create a new key", async () => {
it("Should create a 4S key", async () => {
mockGetAccountData();
const awaitAccountData = awaitAccountDataUpdate("m.secret_storage.default_key");
const bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
@@ -2456,11 +2510,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
sendSyncResponseWithUpdatedAccountData();
// Finally, wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// await account data updated before getting default key.
await awaitAccountData;
const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId();
// Check that the uploaded key in stored in the secret storage
expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy();
@@ -2468,29 +2525,29 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
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 });
it("should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set", async () => {
const awaitAccountDataClientUpdate = awaitAccountDataUpdate("m.secret_storage.default_key");
const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
sendSyncResponseWithUpdatedAccountData();
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// On legacy crypto we need to wait for ClientEvent.AccountData before calling bootstrap again.
await awaitAccountDataClientUpdate;
// Call again bootstrapSecretStorage
await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
// createSecretStorageKey should be called only on the first run of bootstrapSecretStorage
expect(createSecretStorageKey).toHaveBeenCalledTimes(1);
},
);
});
it("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
@@ -2498,10 +2555,10 @@ 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 awaitSecretStorageKeyStoredInAccountData();
await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
sendSyncResponseWithUpdatedAccountData();
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
@@ -2512,10 +2569,10 @@ 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 awaitSecretStorageKeyStoredInAccountData();
await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
sendSyncResponseWithUpdatedAccountData();
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
@@ -2539,7 +2596,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
sendSyncResponseWithUpdatedAccountData();
// Wait for the cross signing keys to be uploaded
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
@@ -2561,6 +2618,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
const backupVersion = "abc";
await bootstrapSecurity(backupVersion);
expect(await aliceClient.getCrypto()!.isSecretStorageReady()).toStrictEqual(true);
// Expect a backup to be available and used
const activeBackup = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
expect(activeBackup).toStrictEqual(backupVersion);
@@ -2596,7 +2655,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
"path:/_matrix/client/v3/room_keys/version",
{
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
},
{ overwriteRoutes: true },
);

View File

@@ -212,6 +212,7 @@ describe("RustCrypto", () => {
it("returns sensible values on a default client", async () => {
const secretStorage = {
isStored: jest.fn().mockResolvedValue(null),
getDefaultKeyId: jest.fn().mockResolvedValue("key"),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, secretStorage);
@@ -232,6 +233,7 @@ describe("RustCrypto", () => {
it("throws if `stop` is called mid-call", async () => {
const secretStorage = {
isStored: jest.fn().mockResolvedValue(null),
getDefaultKeyId: jest.fn().mockResolvedValue(null),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, secretStorage);
@@ -258,7 +260,10 @@ describe("RustCrypto", () => {
});
it("isSecretStorageReady", async () => {
const rustCrypto = await makeTestRustCrypto();
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue(null),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
});

View File

@@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { secretStorageContainsCrossSigningKeys } from "../../../src/rust-crypto/secret-storage";
import {
secretStorageCanAccessSecrets,
secretStorageContainsCrossSigningKeys,
} from "../../../src/rust-crypto/secret-storage";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
describe("secret-storage", () => {
@@ -22,6 +25,7 @@ describe("secret-storage", () => {
it("should return false when the master cross-signing key is not stored in secret storage", async () => {
const secretStorage = {
isStored: jest.fn().mockReturnValue(false),
getDefaultKeyId: jest.fn().mockResolvedValue("SFQ3TbqGOdaaRVfxHtNkn0tvhx0rVj9S"),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
@@ -35,6 +39,7 @@ describe("secret-storage", () => {
if (type === "m.cross_signing.master") return { secretStorageKey: {} };
else return { secretStorageKey2: {} };
},
getDefaultKeyId: jest.fn().mockResolvedValue("SFQ3TbqGOdaaRVfxHtNkn0tvhx0rVj9S"),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
@@ -51,19 +56,73 @@ describe("secret-storage", () => {
return { secretStorageKey2: {} };
}
},
getDefaultKeyId: jest.fn().mockResolvedValue("secretStorageKey"),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeFalsy();
});
it("should return true when there is shared secret storage key between master, user signing and self signing keys", async () => {
it("should return true when master, user signing and self signing keys are all encrypted with default key", async () => {
const secretStorage = {
isStored: jest.fn().mockReturnValue({ secretStorageKey: {} }),
getDefaultKeyId: jest.fn().mockResolvedValue("secretStorageKey"),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeTruthy();
});
it("should return false when master, user signing and self signing keys are all encrypted with a non-default key", async () => {
const secretStorage = {
isStored: jest.fn().mockResolvedValue({ defaultKey: {} }),
getDefaultKeyId: jest.fn().mockResolvedValue("anotherCommonKey"),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeFalsy();
});
});
it("Check canAccessSecrets", async () => {
const secretStorage = {
isStored: jest.fn((secretName) => {
if (secretName == "secretA") {
return { aaaa: {} };
} else if (secretName == "secretB") {
return { bbbb: {} };
} else if (secretName == "secretC") {
return { cccc: {} };
} else if (secretName == "secretD") {
return { aaaa: {} };
} else if (secretName == "secretE") {
return { aaaa: {}, bbbb: {} };
} else {
null;
}
}),
getDefaultKeyId: jest.fn().mockResolvedValue("aaaa"),
} as unknown as ServerSideSecretStorage;
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretE"])).toStrictEqual(true);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA"])).toStrictEqual(true);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretC"])).toStrictEqual(false);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD"])).toStrictEqual(true);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretC"])).toStrictEqual(false);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretC", "secretA"])).toStrictEqual(false);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD", "secretB"])).toStrictEqual(
false,
);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD", "Unknown"])).toStrictEqual(
false,
);
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD", "secretE"])).toStrictEqual(
true,
);
expect(
await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretC", "secretD", "secretE"]),
).toStrictEqual(false);
expect(await secretStorageCanAccessSecrets(secretStorage, [])).toStrictEqual(true);
});
});

View File

@@ -480,7 +480,6 @@ export class BackupManager {
const delay = Math.random() * maxDelay;
await sleep(delay);
if (!this.clientRunning) {
logger.debug("Key backup send aborted, client stopped");
this.sendingBackups = false;
return;
}

View File

@@ -57,7 +57,7 @@ import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
import { Device, DeviceMap } from "../models/device";
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage";
import { CrossSigningIdentity } from "./CrossSigningIdentity";
import { secretStorageContainsCrossSigningKeys } from "./secret-storage";
import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage";
import { keyFromPassphrase } from "../crypto/key_passphrase";
import { encodeRecoveryKey } from "../crypto/recoverykey";
import { crypto } from "../crypto/crypto";
@@ -623,7 +623,20 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
* Implementation of {@link CryptoApi#isSecretStorageReady}
*/
public async isSecretStorageReady(): Promise<boolean> {
return false;
// make sure that the cross-signing keys are stored
const secretsToCheck = [
"m.cross_signing.master",
"m.cross_signing.user_signing",
"m.cross_signing.self_signing",
];
// if key backup is active, we also need to check that the backup decryption key is stored
const keyBackupEnabled = (await this.backupManager.getActiveBackupVersion()) != null;
if (keyBackupEnabled) {
secretsToCheck.push("m.megolm_backup.v1");
}
return secretStorageCanAccessSecrets(this.secretStorage, secretsToCheck);
}
/**

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { ServerSideSecretStorage } from "../secret-storage";
/**
* Check that the private cross signing keys (master, self signing, user signing) are stored in the secret storage and encrypted with the same secret storage key.
* Check that the private cross signing keys (master, self signing, user signing) are stored in the secret storage and encrypted with the default secret storage key.
*
* @param secretStorage - The secret store using account data
* @returns True if the cross-signing keys are all stored and encrypted with the same secret storage key.
@@ -25,20 +25,36 @@ import { ServerSideSecretStorage } from "../secret-storage";
* @internal
*/
export async function secretStorageContainsCrossSigningKeys(secretStorage: ServerSideSecretStorage): Promise<boolean> {
// Check if the master cross-signing key is stored in secret storage
const secretStorageMasterKeys = await secretStorage.isStored("m.cross_signing.master");
// Master key not stored
if (!secretStorageMasterKeys) return false;
// Get the user signing keys stored into the secret storage
const secretStorageUserSigningKeys = (await secretStorage.isStored(`m.cross_signing.user_signing`)) || {};
// Get the self signing keys stored into the secret storage
const secretStorageSelfSigningKeys = (await secretStorage.isStored(`m.cross_signing.self_signing`)) || {};
// Check that one of the secret storage keys used to encrypt the master key was also used to encrypt the user-signing and self-signing keys
return Object.keys(secretStorageMasterKeys).some(
(secretStorageKey) =>
secretStorageUserSigningKeys[secretStorageKey] && secretStorageSelfSigningKeys[secretStorageKey],
);
return secretStorageCanAccessSecrets(secretStorage, [
"m.cross_signing.master",
"m.cross_signing.user_signing",
"m.cross_signing.self_signing",
]);
}
/**
*
* Check that the secret storage can access the given secrets using the default key.
*
* @param secretStorage - The secret store using account data
* @param secretNames - The secret names to check
* @returns True if all the given secrets are accessible and encrypted with the given key.
*
* @internal
*/
export async function secretStorageCanAccessSecrets(
secretStorage: ServerSideSecretStorage,
secretNames: string[],
): Promise<boolean> {
const defaultKeyId = await secretStorage.getDefaultKeyId();
if (!defaultKeyId) return false;
for (const secretName of secretNames) {
// check which keys this particular secret is encrypted with
const record = (await secretStorage.isStored(secretName)) || {};
// if it's not encrypted with the right key, there is no point continuing
if (!(defaultKeyId in record)) return false;
}
return true;
}