Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ test.describe("Encryption tab", () => {

test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials);
const botCredentials = { ...credentials };
delete botCredentials.accessToken; // use a new login for the bot
const res = await createBot(page, homeserver, botCredentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ test.describe("Recovery section in Encryption tab", () => {
let recoveryKey: GeneratedSecretStorageKey;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials);
const botCredentials = { ...credentials };
delete botCredentials.accessToken; // use a new login for the bot
const res = await createBot(page, homeserver, botCredentials);
recoveryKey = res.recoveryKey;
});

Expand Down
43 changes: 40 additions & 3 deletions playwright/pages/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { bootstrapCrossSigningForClient, Client } from "./client";

export interface CredentialsOptionalAccessToken extends Omit<Credentials, "accessToken"> {
accessToken?: string;
}

export interface CreateBotOpts {
/**
* A prefix to use for the userid. If unspecified, "bot_" will be used.
Expand Down Expand Up @@ -58,7 +62,7 @@ const defaultCreateBotOptions = {
type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };

export class Bot extends Client {
public credentials?: Credentials;
public credentials?: CredentialsOptionalAccessToken;
private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>;

constructor(
Expand All @@ -70,7 +74,16 @@ export class Bot extends Client {
this.opts = Object.assign({}, defaultCreateBotOptions, opts);
}

public setCredentials(credentials: Credentials): void {
/**
* Set the credentials used by the bot.
*
* If `credentials.accessToken` is unset, then `buildClient` will log in a
* new session. Note that `getCredentials` will return the credentials
* passed to this function, rather than the updated credentials from the new
* login. In particular, the `accessToken` and `deviceId` will not be
* updated.
*/
public setCredentials(credentials: CredentialsOptionalAccessToken): void {
if (this.credentials) throw new Error("Bot has already started");
this.credentials = credentials;
}
Expand All @@ -80,7 +93,7 @@ export class Bot extends Client {
return client.evaluate((cli) => cli.__playwright_recovery_key);
}

private async getCredentials(): Promise<Credentials> {
private async getCredentials(): Promise<CredentialsOptionalAccessToken> {
if (this.credentials) return this.credentials;
// We want to pad the uniqueId but not the prefix
const username =
Expand Down Expand Up @@ -161,6 +174,30 @@ export class Bot extends Client {
getSecretStorageKey,
};

if (!("accessToken" in credentials)) {
const loginCli = new window.matrixcs.MatrixClient({
baseUrl,
store: new window.matrixcs.MemoryStore(),
scheduler: new window.matrixcs.MatrixScheduler(),
cryptoStore: new window.matrixcs.MemoryCryptoStore(),
cryptoCallbacks,
logger,
});

const loginResponse = await loginCli.loginRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: credentials.userId,
},
password: credentials.password,
});

credentials.accessToken = loginResponse.access_token;
credentials.userId = loginResponse.user_id;
credentials.deviceId = loginResponse.device_id;
}

const cli = new window.matrixcs.MatrixClient({
baseUrl,
userId: credentials.userId,
Expand Down
6 changes: 3 additions & 3 deletions playwright/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type {
EmptyObject,
} from "matrix-js-sdk/src/matrix";
import type { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import { type Credentials } from "../plugins/homeserver";
import { type CredentialsOptionalAccessToken } from "./bot";

export class Client {
public network: Network;
Expand Down Expand Up @@ -424,7 +424,7 @@ export class Client {
/**
* Bootstraps cross-signing.
*/
public async bootstrapCrossSigning(credentials: Credentials): Promise<void> {
public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise<void> {
const client = await this.prepareClient();
return bootstrapCrossSigningForClient(client, credentials);
}
Expand Down Expand Up @@ -522,7 +522,7 @@ export class Client {
*/
export function bootstrapCrossSigningForClient(
client: JSHandle<MatrixClient>,
credentials: Credentials,
credentials: CredentialsOptionalAccessToken,
resetKeys: boolean = false,
) {
return client.evaluate(
Expand Down
130 changes: 95 additions & 35 deletions src/DeviceListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
RoomStateEvent,
type SyncState,
ClientStoppedError,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
Expand All @@ -29,7 +30,6 @@ import {
} from "./toasts/BulkUnverifiedSessionsToast";
import {
hideToast as hideSetupEncryptionToast,
Kind as SetupKind,
showToast as showSetupEncryptionToast,
} from "./toasts/SetupEncryptionToast";
import {
Expand Down Expand Up @@ -65,7 +65,48 @@ export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";

const logger = baseLogger.getChild("DeviceListener:");

export default class DeviceListener {
/**
* The state of the device and the user's account.
*/
export enum DeviceState {
/**
* The device is in a good state.
*/
OK = "ok",
/**
* The user needs to set up recovery.
*/
SET_UP_RECOVERY = "set_up_recovery",
/**
* The device is not verified.
*/
VERIFY_THIS_SESSION = "verify_this_session",
/**
* Key storage is out of sync (keys are missing locally, from recovery, or both).
*/
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
/**
* Key storage is not enabled, and has not been marked as purposely disabled.
*/
TURN_ON_KEY_STORAGE = "turn_on_key_storage",
/**
* The user's identity needs resetting, due to missing keys.
*/
IDENTITY_NEEDS_RESET = "identity_needs_reset",
}
Comment on lines +68 to +96
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use string union? It allows more flexibility


/**
* The events emitted by {@link DeviceListener}
*/
export enum DeviceListenerEvents {
DeviceState = "device_state",
}

type EventHandlerMap = {
[DeviceListenerEvents.DeviceState]: (state: DeviceState) => void;
};

export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
private dispatcherRef?: string;
// device IDs for which the user has dismissed the verify toast ('Later')
private dismissed = new Set<string>();
Expand All @@ -87,6 +128,7 @@ export default class DeviceListener {
private shouldRecordClientInformation = false;
private enableBulkUnverifiedSessionsReminder = true;
private deviceClientInformationSettingWatcherRef: string | undefined;
private deviceState: DeviceState = DeviceState.OK;

// Remember the current analytics state to avoid sending the same event multiple times.
private analyticsVerificationState?: string;
Expand Down Expand Up @@ -198,8 +240,8 @@ export default class DeviceListener {
}

/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of cross-signing keys.
* If the device is in a `DeviceState.KEY_STORAGE_OUT_OF_SYNC` state, check if
* it requires a reset of cross-signing keys.
*
* We will reset cross-signing keys if both our local cache and 4S don't
* have all cross-signing keys.
Expand Down Expand Up @@ -227,16 +269,15 @@ export default class DeviceListener {
}

/**
* If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck}
* requires a reset of key backup.
* If the device is in a `DeviceState.KEY_STORAGE_OUT_OF_SYNC` state, check if
* it requires a reset of key backup.
*
* If the user has their recovery key, we need to reset backup if:
* - the user hasn't disabled backup,
* - we don't have the backup key cached locally, *and*
* - we don't have the backup key stored in 4S.
* (The user should already have a key backup created at this point,
* otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE`
* condition.)
* (The user should already have a key backup created at this point, the
* device state would be `DeviceState.TURN_ON_KEY_STORAGE`.)
*
* If the user has forgotten their recovery key, we need to reset backup if:
* - the user hasn't disabled backup, and
Expand Down Expand Up @@ -425,11 +466,9 @@ export default class DeviceListener {

const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;

const isCurrentDeviceTrusted =
crossSigningReady &&
Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
const isCurrentDeviceTrusted = Boolean(
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);

const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
const backupDisabled = await this.recheckBackupDisabled(cli);
Expand All @@ -448,65 +487,70 @@ export default class DeviceListener {

await this.reportCryptoSessionStateToAnalytics(cli);

if (this.dismissedThisDeviceToast || allSystemsReady) {
if (allSystemsReady) {
logSpan.info("No toast needed");
hideSetupEncryptionToast();
await this.setDeviceState(DeviceState.OK, logSpan);

this.checkKeyBackupStatus();
} else if (await this.shouldShowSetupEncryptionToast()) {
} else {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);

if (!isCurrentDeviceTrusted) {
// the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
await this.setDeviceState(DeviceState.VERIFY_THIS_SESSION, logSpan);
} else if (!allCrossSigningSecretsCached) {
// cross signing ready & device trusted, but we are missing secrets from our local cache.
// prompt the user to enter their recovery key.
logSpan.info(
"Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast",
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
crossSigningStatus.privateKeysCachedLocally,
crossSigningStatus.privateKeysInSecretStorage,
);
await this.setDeviceState(
crossSigningStatus.privateKeysInSecretStorage
? DeviceState.KEY_STORAGE_OUT_OF_SYNC
: DeviceState.IDENTITY_NEEDS_RESET,
logSpan,
);
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
} else if (!keyBackupIsOk) {
logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast");
showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE);
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
await this.setDeviceState(DeviceState.TURN_ON_KEY_STORAGE, logSpan);
} else if (secretStorageStatus.defaultKeyId === null) {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
if (recoveryDisabled) {
logSpan.info("Recovery disabled: no toast needed");
hideSetupEncryptionToast();
await this.setDeviceState(DeviceState.OK, logSpan);
} else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
await this.setDeviceState(DeviceState.SET_UP_RECOVERY, logSpan);
} else {
logSpan.info("No default 4S key but backup disabled: no toast needed");
hideSetupEncryptionToast();
await this.setDeviceState(DeviceState.OK, logSpan);
}
} else {
// If we get here, then we are verified, have key backup, and
// 4S, but allSystemsReady is false, which means that either
// secretStorageStatus.ready is false (which means that 4S
// doesn't have all the secrets), or we don't have the backup
// key cached locally.
// key cached locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
logSpan.warn("4S is missing secrets or backup key not cached", {
crossSigningReady,
secretStorageStatus,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
backupKeyCached,
});
// We use the right toast variant based on whether the backup
// key is missing locally. If any of the cross-signing keys are
// missing locally, that is handled by the
// `!allCrossSigningSecretsCached` branch above.
showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC);
await this.setDeviceState(DeviceState.KEY_STORAGE_OUT_OF_SYNC, logSpan);
}
if (this.dismissedThisDeviceToast) {
this.checkKeyBackupStatus();
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}

// This needs to be done after awaiting on getUserDeviceInfo() above, so
Expand Down Expand Up @@ -598,6 +642,22 @@ export default class DeviceListener {
return recoveryStatus?.enabled === false;
}

public getDeviceState(): DeviceState {
return this.deviceState;
}

private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing tsdoc

this.deviceState = newState;
this.emit(DeviceListenerEvents.DeviceState, newState);
if (newState === DeviceState.OK || this.dismissedThisDeviceToast) {
hideSetupEncryptionToast();
} else if (await this.shouldShowSetupEncryptionToast()) {
showSetupEncryptionToast(newState);
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
}
}

/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
Expand Down
Loading
Loading