Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a5f9df5

Browse files
authored
Support staged rollout of migration to Rust Crypto (#12184)
* Rust migration staged rollout * Phased rollout unit tests
1 parent 73b1623 commit a5f9df5

File tree

8 files changed

+369
-6
lines changed

8 files changed

+369
-6
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"highlight.js": "^11.3.1",
9696
"html-entities": "^2.0.0",
9797
"is-ip": "^3.1.0",
98+
"js-xxhash": "^3.0.1",
9899
"jszip": "^3.7.0",
99100
"katex": "^0.16.0",
100101
"linkify-element": "4.1.3",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright 2024 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 { test, expect } from "../../element-web-test";
18+
import { logIntoElement } from "./utils";
19+
20+
test.describe("Migration of existing logins", () => {
21+
test("Test migration of existing logins when rollout is 100%", async ({
22+
page,
23+
context,
24+
app,
25+
credentials,
26+
homeserver,
27+
}, workerInfo) => {
28+
test.skip(workerInfo.project.name === "Rust Crypto", "This test only works with Rust crypto.");
29+
await page.goto("/#/login");
30+
31+
let featureRustCrypto = false;
32+
let stagedRolloutPercent = 0;
33+
34+
await context.route(`http://localhost:8080/config.json*`, async (route) => {
35+
const json = {};
36+
json["features"] = {
37+
feature_rust_crypto: featureRustCrypto,
38+
};
39+
json["setting_defaults"] = {
40+
"RustCrypto.staged_rollout_percent": stagedRolloutPercent,
41+
};
42+
await route.fulfill({ json });
43+
});
44+
45+
await logIntoElement(page, homeserver, credentials);
46+
47+
await app.settings.openUserSettings("Help & About");
48+
await expect(page.getByText("Crypto version: Olm")).toBeVisible();
49+
50+
featureRustCrypto = true;
51+
52+
await page.reload();
53+
54+
await app.settings.openUserSettings("Help & About");
55+
await expect(page.getByText("Crypto version: Olm")).toBeVisible();
56+
57+
stagedRolloutPercent = 100;
58+
59+
await page.reload();
60+
61+
await app.settings.openUserSettings("Help & About");
62+
await expect(page.getByText("Crypto version: Rust SDK")).toBeVisible();
63+
});
64+
});

src/MatrixClientPeg.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ limitations under the License.
1818
*/
1919

2020
import {
21-
ICreateClientOpts,
22-
PendingEventOrdering,
23-
RoomNameState,
24-
RoomNameType,
2521
EventTimeline,
2622
EventTimelineSet,
23+
ICreateClientOpts,
2724
IStartClientOpts,
2825
MatrixClient,
2926
MemoryStore,
27+
PendingEventOrdering,
28+
RoomNameState,
29+
RoomNameType,
3030
TokenRefreshFunction,
3131
} from "matrix-js-sdk/src/matrix";
3232
import * as utils from "matrix-js-sdk/src/utils";
@@ -53,6 +53,7 @@ import PlatformPeg from "./PlatformPeg";
5353
import { formatList } from "./utils/FormattingUtils";
5454
import SdkConfig from "./SdkConfig";
5555
import { Features } from "./settings/Settings";
56+
import { PhasedRolloutFeature } from "./utils/PhasedRolloutFeature";
5657

5758
export interface IMatrixClientCreds {
5859
homeserverUrl: string;
@@ -302,13 +303,34 @@ class MatrixClientPegClass implements IMatrixClientPeg {
302303
throw new Error("createClient must be called first");
303304
}
304305

305-
const useRustCrypto = SettingsStore.getValue(Features.RustCrypto);
306+
let useRustCrypto = SettingsStore.getValue(Features.RustCrypto);
307+
308+
// We want the value that is set in the config.json for that web instance
309+
const defaultUseRustCrypto = SettingsStore.getValueAt(SettingLevel.CONFIG, Features.RustCrypto);
310+
const migrationPercent = SettingsStore.getValueAt(SettingLevel.CONFIG, "RustCrypto.staged_rollout_percent");
311+
312+
// If the default config is to use rust crypto, and the user is on legacy crypto,
313+
// we want to check if we should migrate the current user.
314+
if (!useRustCrypto && defaultUseRustCrypto && Number.isInteger(migrationPercent)) {
315+
// The user is not on rust crypto, but the default stack is now rust; Let's check if we should migrate
316+
// the current user to rust crypto.
317+
try {
318+
const stagedRollout = new PhasedRolloutFeature("RustCrypto.staged_rollout_percent", migrationPercent);
319+
// Device id should not be null at that point, or init crypto will fail anyhow
320+
const deviceId = this.matrixClient.getDeviceId()!;
321+
// we use deviceId rather than userId because we don't particularly want all devices
322+
// of a user to be migrated at the same time.
323+
useRustCrypto = stagedRollout.isFeatureEnabled(deviceId);
324+
} catch (e) {
325+
logger.warn("Failed to create staged rollout feature for rust crypto migration", e);
326+
}
327+
}
306328

307329
// we want to make sure that the same crypto implementation is used throughout the lifetime of a device,
308330
// so persist the setting at the device layer
309331
// (At some point, we'll allow the user to *enable* the setting via labs, which will migrate their existing
310332
// device to the rust-sdk implementation, but that won't change anything here).
311-
await SettingsStore.setValue("feature_rust_crypto", null, SettingLevel.DEVICE, useRustCrypto);
333+
await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, useRustCrypto);
312334

313335
// Now we can initialise the right crypto impl.
314336
if (useRustCrypto) {

src/settings/Settings.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export enum Features {
9696
VoiceBroadcastForceSmallChunks = "feature_voice_broadcast_force_small_chunks",
9797
NotificationSettings2 = "feature_notification_settings2",
9898
OidcNativeFlow = "feature_oidc_native_flow",
99+
// If true, every new login will use the new rust crypto implementation
99100
RustCrypto = "feature_rust_crypto",
100101
}
101102

@@ -503,6 +504,13 @@ export const SETTINGS: { [setting: string]: ISetting } = {
503504
default: false,
504505
controller: new RustCryptoSdkController(),
505506
},
507+
// Must be set under `setting_defaults` in config.json.
508+
// If set to 100 in conjunction with `feature_rust_crypto`, all existing users will migrate to the new crypto.
509+
// Default is 0, meaning no existing users on legacy crypto will migrate.
510+
"RustCrypto.staged_rollout_percent": {
511+
supportedLevels: [SettingLevel.CONFIG],
512+
default: 0,
513+
},
506514
"baseFontSize": {
507515
displayName: _td("settings|appearance|font_size"),
508516
supportedLevels: LEVELS_ACCOUNT_SETTINGS,

src/utils/PhasedRolloutFeature.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright 2024 New Vector Ltd
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 { xxHash32 } from "js-xxhash";
18+
19+
/**
20+
* The PhasedRolloutFeature class is used to manage the phased rollout of a new feature.
21+
*
22+
* It uses a hash of the user's identifier and the feature name to determine if a feature is enabled for a specific user.
23+
* The rollout percentage determines the probability that a user will be enabled for the feature.
24+
* The feature will be enabled for all users if the rollout percentage is 100, and for no users if the percentage is 0.
25+
* If a user is enabled for a feature at x% rollout, it will also be for any greater than x percent.
26+
*
27+
* The process ensures a uniform distribution of enabled features across users.
28+
*
29+
* @property featureName - The name of the feature to be rolled out.
30+
* @property rolloutPercentage - The int percentage (0..100) of users for whom the feature should be enabled.
31+
*/
32+
export class PhasedRolloutFeature {
33+
public readonly featureName: string;
34+
private readonly rolloutPercentage: number;
35+
private readonly seed: number;
36+
37+
public constructor(featureName: string, rolloutPercentage: number) {
38+
this.featureName = featureName;
39+
if (!Number.isInteger(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100) {
40+
throw new Error("Rollout percentage must be an integer between 0 and 100");
41+
}
42+
this.rolloutPercentage = rolloutPercentage;
43+
// We add the feature name for the seed to ensure that the hash is different for each feature
44+
this.seed = Array.from(featureName).reduce((sum, char) => sum + char.charCodeAt(0), 0);
45+
}
46+
47+
/**
48+
* Returns true if the feature should be enabled for the given user.
49+
* @param userIdentifier - Some unique identifier for the user, e.g. their user ID or device ID.
50+
*/
51+
public isFeatureEnabled(userIdentifier: string): boolean {
52+
/*
53+
* We use a hash function to convert the unique user ID string into an integer.
54+
* This integer can then be used as a basis for deciding whether the user should have access to the new feature.
55+
* We need some hash with good uniform distribution properties, security is not a concern here.
56+
* We use xxHash32, which is fast and has good distribution properties.
57+
*/
58+
const hash = xxHash32(userIdentifier, this.seed);
59+
// We use the hash modulo 100 to get a number between 0 and 99.
60+
// Modulo is simple and effective and the distribution should be uniform enough for our purposes.
61+
return hash % 100 < this.rolloutPercentage;
62+
}
63+
}

test/MatrixClientPeg-test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,117 @@ describe("MatrixClientPeg", () => {
144144
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true);
145145
});
146146

147+
describe("Rust staged rollout", () => {
148+
function mockSettingStore(
149+
userIsUsingRust: boolean,
150+
newLoginShouldUseRust: boolean,
151+
rolloutPercent: number | null,
152+
) {
153+
const originalGetValue = SettingsStore.getValue;
154+
jest.spyOn(SettingsStore, "getValue").mockImplementation(
155+
(settingName: string, roomId: string | null = null, excludeDefault = false) => {
156+
if (settingName === "feature_rust_crypto") {
157+
return userIsUsingRust;
158+
}
159+
return originalGetValue(settingName, roomId, excludeDefault);
160+
},
161+
);
162+
const originalGetValueAt = SettingsStore.getValueAt;
163+
jest.spyOn(SettingsStore, "getValueAt").mockImplementation(
164+
(level: SettingLevel, settingName: string) => {
165+
if (settingName === "feature_rust_crypto") {
166+
return newLoginShouldUseRust;
167+
}
168+
// if null we let the original implementation handle it to get the default
169+
if (settingName === "RustCrypto.staged_rollout_percent" && rolloutPercent !== null) {
170+
return rolloutPercent;
171+
}
172+
return originalGetValueAt(level, settingName);
173+
},
174+
);
175+
}
176+
177+
let mockSetValue: jest.SpyInstance;
178+
let mockInitCrypto: jest.SpyInstance;
179+
let mockInitRustCrypto: jest.SpyInstance;
180+
181+
beforeEach(() => {
182+
mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
183+
mockInitCrypto = jest.spyOn(testPeg.safeGet(), "initCrypto").mockResolvedValue(undefined);
184+
mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
185+
});
186+
187+
it("Should not migrate existing login if rollout is 0", async () => {
188+
mockSettingStore(false, true, 0);
189+
190+
await testPeg.start();
191+
expect(mockInitCrypto).toHaveBeenCalled();
192+
expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1);
193+
194+
// we should have stashed the setting in the settings store
195+
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false);
196+
});
197+
198+
it("Should migrate existing login if rollout is 100", async () => {
199+
mockSettingStore(false, true, 100);
200+
await testPeg.start();
201+
expect(mockInitCrypto).not.toHaveBeenCalled();
202+
expect(mockInitRustCrypto).toHaveBeenCalledTimes(1);
203+
204+
// we should have stashed the setting in the settings store
205+
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true);
206+
});
207+
208+
it("Should migrate existing login if user is in rollout bucket", async () => {
209+
mockSettingStore(false, true, 30);
210+
211+
// Use a device id that is known to be in the 30% bucket (hash modulo 100 < 30)
212+
const spy = jest.spyOn(testPeg.get()!, "getDeviceId").mockReturnValue("AAA");
213+
214+
await testPeg.start();
215+
expect(mockInitCrypto).not.toHaveBeenCalled();
216+
expect(mockInitRustCrypto).toHaveBeenCalledTimes(1);
217+
218+
// we should have stashed the setting in the settings store
219+
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true);
220+
221+
spy.mockReset();
222+
});
223+
224+
it("Should not migrate existing login if rollout is malformed", async () => {
225+
mockSettingStore(false, true, 100.1);
226+
227+
await testPeg.start();
228+
expect(mockInitCrypto).toHaveBeenCalled();
229+
expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1);
230+
231+
// we should have stashed the setting in the settings store
232+
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false);
233+
});
234+
235+
it("Default is to not migrate", async () => {
236+
mockSettingStore(false, true, null);
237+
238+
await testPeg.start();
239+
expect(mockInitCrypto).toHaveBeenCalled();
240+
expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1);
241+
242+
// we should have stashed the setting in the settings store
243+
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false);
244+
});
245+
246+
it("Should not migrate if feature_rust_crypto is false", async () => {
247+
mockSettingStore(false, false, 100);
248+
249+
await testPeg.start();
250+
expect(mockInitCrypto).toHaveBeenCalled();
251+
expect(mockInitRustCrypto).not.toHaveBeenCalledTimes(1);
252+
253+
// we should have stashed the setting in the settings store
254+
expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, false);
255+
});
256+
});
257+
147258
it("should reload when store database closes for a guest user", async () => {
148259
testPeg.safeGet().isGuest = () => true;
149260
const emitter = new EventEmitter();

0 commit comments

Comments
 (0)