Skip to content

Commit 61adb84

Browse files
richvdhtexuf
authored andcommitted
Split SecretStorage into two parts (matrix-org#3267)
* Pull `SecretStorageCallbacks` out of `ICryptoCallbacks` * Pull the storage part of SecretStorage out to a new class * Move SecretSharing to a separate class * Move `ISecretRequest` into `SecretSharing.ts` * Pull out ISecretStorage interface, and use it * Mark old `SecretStorage` as deprecated, and rename accesses to it * Move a `SecretStorage` unit test into its own file * Use new `SecretStorage` in a couple of places * add some more unit tests * Fix test file name ... to match the unit under test * even more tests * Add a load of comments * Rename classes * Fix some broken tsdoc links * fix broken test * Fix compaints about superlinear regex * just one more test
1 parent 79e189f commit 61adb84

File tree

7 files changed

+1081
-530
lines changed

7 files changed

+1081
-530
lines changed

spec/unit/crypto/secrets.spec.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,7 @@ import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "
2727
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
2828
import { ISignatures } from "../../../src/@types/signed";
2929
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
30-
import {
31-
SecretStorageKeyDescription,
32-
SECRET_STORAGE_ALGORITHM_V1_AES,
33-
AccountDataClient,
34-
} from "../../../src/secret-storage";
35-
import { SecretStorage } from "../../../src/crypto/SecretStorage";
30+
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
3631

3732
async function makeTestClient(
3833
userInfo: { userId: string; deviceId: string },
@@ -81,22 +76,6 @@ describe("Secrets", function () {
8176
return global.Olm.init();
8277
});
8378

84-
it("should allow storing a default key", async function () {
85-
const accountDataAdapter = {
86-
getAccountDataFromServer: jest.fn().mockResolvedValue(null),
87-
setAccountData: jest.fn().mockResolvedValue({}),
88-
};
89-
const secretStorage = new SecretStorage(accountDataAdapter as unknown as AccountDataClient, {}, undefined);
90-
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
91-
92-
// it should have made up a 32-character key id
93-
expect(result.keyId.length).toEqual(32);
94-
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
95-
`m.secret_storage.key.${result.keyId}`,
96-
result.keyInfo,
97-
);
98-
});
99-
10079
it("should store and retrieve a secret", async function () {
10180
const key = new Uint8Array(16);
10281
for (let i = 0; i < 16; i++) key[i] = i;

spec/unit/secret-storage.spec.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/*
2+
Copyright 2019, 2022-2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Mocked } from "jest-mock";
18+
19+
import {
20+
AccountDataClient,
21+
PassphraseInfo,
22+
SecretStorageCallbacks,
23+
SecretStorageKeyDescriptionAesV1,
24+
SecretStorageKeyDescriptionCommon,
25+
ServerSideSecretStorageImpl,
26+
trimTrailingEquals,
27+
} from "../../src/secret-storage";
28+
import { calculateKeyCheck } from "../../src/crypto/aes";
29+
import { randomString } from "../../src/randomstring";
30+
31+
describe("ServerSideSecretStorageImpl", function () {
32+
describe(".addKey", function () {
33+
it("should allow storing a default key", async function () {
34+
const accountDataAdapter = mockAccountDataClient();
35+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
36+
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
37+
38+
// it should have made up a 32-character key id
39+
expect(result.keyId.length).toEqual(32);
40+
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
41+
`m.secret_storage.key.${result.keyId}`,
42+
result.keyInfo,
43+
);
44+
});
45+
46+
it("should allow storing a key with an explicit id", async function () {
47+
const accountDataAdapter = mockAccountDataClient();
48+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
49+
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {}, "myKeyId");
50+
51+
// it should have made up a 32-character key id
52+
expect(result.keyId).toEqual("myKeyId");
53+
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
54+
"m.secret_storage.key.myKeyId",
55+
result.keyInfo,
56+
);
57+
});
58+
59+
it("should allow storing a key with a name", async function () {
60+
const accountDataAdapter = mockAccountDataClient();
61+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
62+
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", { name: "mykey" });
63+
64+
expect(result.keyInfo.name).toEqual("mykey");
65+
66+
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
67+
`m.secret_storage.key.${result.keyId}`,
68+
result.keyInfo,
69+
);
70+
});
71+
72+
it("should allow storing a key with a passphrase", async function () {
73+
const accountDataAdapter = mockAccountDataClient();
74+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
75+
const passphrase: PassphraseInfo = {
76+
algorithm: "m.pbkdf2",
77+
iterations: 125,
78+
salt: "saltygoodness",
79+
bits: 256,
80+
};
81+
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
82+
passphrase,
83+
});
84+
85+
expect(result.keyInfo.passphrase).toEqual(passphrase);
86+
87+
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
88+
`m.secret_storage.key.${result.keyId}`,
89+
result.keyInfo,
90+
);
91+
});
92+
93+
it("should complain about invalid algorithm", async function () {
94+
const accountDataAdapter = mockAccountDataClient();
95+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
96+
await expect(() => secretStorage.addKey("bad_alg")).rejects.toThrow("Unknown key algorithm");
97+
});
98+
});
99+
100+
describe("getKey", function () {
101+
it("should return the specified key", async function () {
102+
const accountDataAdapter = mockAccountDataClient();
103+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
104+
105+
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
106+
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T> {
107+
if (eventType === "m.secret_storage.key.my_key") {
108+
return storedKey as unknown as T;
109+
} else {
110+
throw new Error(`unexpected eventType ${eventType}`);
111+
}
112+
}
113+
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
114+
115+
const result = await secretStorage.getKey("my_key");
116+
expect(result).toEqual(["my_key", storedKey]);
117+
});
118+
119+
it("should return the default key if none is specified", async function () {
120+
const accountDataAdapter = mockAccountDataClient();
121+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
122+
123+
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
124+
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T> {
125+
if (eventType === "m.secret_storage.default_key") {
126+
return { key: "default_key_id" } as unknown as T;
127+
} else if (eventType === "m.secret_storage.key.default_key_id") {
128+
return storedKey as unknown as T;
129+
} else {
130+
throw new Error(`unexpected eventType ${eventType}`);
131+
}
132+
}
133+
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
134+
135+
const result = await secretStorage.getKey();
136+
expect(result).toEqual(["default_key_id", storedKey]);
137+
});
138+
139+
it("should return null if the key is not found", async function () {
140+
const accountDataAdapter = mockAccountDataClient();
141+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
142+
// @ts-ignore
143+
accountDataAdapter.getAccountDataFromServer.mockResolvedValue(null);
144+
145+
const result = await secretStorage.getKey("my_key");
146+
expect(result).toEqual(null);
147+
});
148+
});
149+
150+
describe("checkKey", function () {
151+
it("should return true for a correct key check", async function () {
152+
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
153+
154+
const myKey = new TextEncoder().encode(randomString(32));
155+
const { iv, mac } = await calculateKeyCheck(myKey);
156+
157+
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
158+
name: "my key",
159+
passphrase: {} as PassphraseInfo,
160+
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
161+
iv,
162+
mac,
163+
};
164+
165+
const result = await secretStorage.checkKey(myKey, keyInfo);
166+
expect(result).toBe(true);
167+
});
168+
169+
it("should return false for an incorrect key check", async function () {
170+
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
171+
172+
const { iv, mac } = await calculateKeyCheck(new TextEncoder().encode("badkey"));
173+
174+
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
175+
name: "my key",
176+
passphrase: {} as PassphraseInfo,
177+
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
178+
iv,
179+
mac,
180+
};
181+
182+
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
183+
expect(result).toBe(false);
184+
});
185+
186+
it("should raise for an unknown algorithm", async function () {
187+
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
188+
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
189+
name: "my key",
190+
passphrase: {} as PassphraseInfo,
191+
algorithm: "bad_alg",
192+
iv: "iv",
193+
mac: "mac",
194+
};
195+
196+
await expect(() => secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo)).rejects.toThrow(
197+
"Unknown algorithm",
198+
);
199+
});
200+
201+
// XXX: really???
202+
it("should return true for an absent mac", async function () {
203+
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
204+
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
205+
name: "my key",
206+
passphrase: {} as PassphraseInfo,
207+
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
208+
iv: "iv",
209+
mac: "",
210+
};
211+
212+
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
213+
expect(result).toBe(true);
214+
});
215+
});
216+
217+
describe("store", () => {
218+
it("should ignore keys with unknown algorithm", async function () {
219+
const accountDataAdapter = mockAccountDataClient();
220+
const mockCallbacks = { getSecretStorageKey: jest.fn() } as Mocked<SecretStorageCallbacks>;
221+
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, mockCallbacks);
222+
223+
// stub out getAccountData to return a key with an unknown algorithm
224+
const storedKey = { algorithm: "badalg" } as SecretStorageKeyDescriptionCommon;
225+
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T> {
226+
if (eventType === "m.secret_storage.key.keyid") {
227+
return storedKey as unknown as T;
228+
} else {
229+
throw new Error(`unexpected eventType ${eventType}`);
230+
}
231+
}
232+
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
233+
234+
// suppress the expected warning on the console
235+
jest.spyOn(console, "warn").mockImplementation();
236+
237+
// now attempt the store
238+
await secretStorage.store("mysecret", "supersecret", ["keyid"]);
239+
240+
// we should have stored... nothing
241+
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith("mysecret", { encrypted: {} });
242+
243+
// ... and emitted a warning.
244+
// eslint-disable-next-line no-console
245+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("unknown algorithm"));
246+
});
247+
});
248+
});
249+
250+
describe("trimTrailingEquals", () => {
251+
it("should strip trailing =", () => {
252+
expect(trimTrailingEquals("ab=c===")).toEqual("ab=c");
253+
});
254+
255+
it("should leave strings without trailing = alone", () => {
256+
expect(trimTrailingEquals("ab=c")).toEqual("ab=c");
257+
});
258+
259+
it("should leave the empty string alone", () => {
260+
const result = trimTrailingEquals("");
261+
expect(result).toEqual("");
262+
});
263+
});
264+
265+
function mockAccountDataClient(): Mocked<AccountDataClient> {
266+
return {
267+
getAccountDataFromServer: jest.fn().mockResolvedValue(null),
268+
setAccountData: jest.fn().mockResolvedValue({}),
269+
} as unknown as Mocked<AccountDataClient>;
270+
}

src/crypto/CrossSigning.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@ import { logger } from "../logger";
2525
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
2626
import { decryptAES, encryptAES } from "./aes";
2727
import { DeviceInfo } from "./deviceinfo";
28-
import { SecretStorage } from "./SecretStorage";
2928
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
3029
import { OlmDevice } from "./OlmDevice";
3130
import { ICryptoCallbacks } from ".";
3231
import { ISignatures } from "../@types/signed";
3332
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
34-
import { SecretStorageKeyDescription } from "../secret-storage";
33+
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
3534

3635
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
3736

@@ -164,7 +163,7 @@ export class CrossSigningInfo {
164163
* key
165164
*/
166165
public async isStoredInSecretStorage(
167-
secretStorage: SecretStorage<MatrixClient | undefined>,
166+
secretStorage: ServerSideSecretStorage,
168167
): Promise<Record<string, object> | null> {
169168
// check what SSSS keys have encrypted the master key (if any)
170169
const stored = (await secretStorage.isStored("m.cross_signing.master")) || {};
@@ -192,7 +191,7 @@ export class CrossSigningInfo {
192191
*/
193192
public static async storeInSecretStorage(
194193
keys: Map<string, Uint8Array>,
195-
secretStorage: SecretStorage<undefined>,
194+
secretStorage: ServerSideSecretStorage,
196195
): Promise<void> {
197196
for (const [type, privateKey] of keys) {
198197
const encodedKey = encodeBase64(privateKey);
@@ -209,7 +208,10 @@ export class CrossSigningInfo {
209208
* @param secretStorage - The secret store using account data
210209
* @returns The private key
211210
*/
212-
public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array | null> {
211+
public static async getFromSecretStorage(
212+
type: string,
213+
secretStorage: ServerSideSecretStorage,
214+
): Promise<Uint8Array | null> {
213215
const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
214216
if (!encodedKey) {
215217
return null;

0 commit comments

Comments
 (0)