This commit is contained in:
David Baker 2024-12-11 12:57:02 +00:00 committed by GitHub
commit cd6657ecf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 97 additions and 21 deletions

View file

@ -23,11 +23,11 @@ import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDia
async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise<boolean> { async function canUploadKeysWithPasswordOnly(cli: MatrixClient): Promise<boolean> {
try { try {
await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys); await cli.uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys);
// We should never get here: the server should always require // If we get here, it's because the server is allowing us to upload keys without
// UI auth to upload device signing keys. If we do, we upload // auth the first time due to MSC3967. Therefore, yes, we can upload keys
// no keys which would be a no-op. // (with or without password, technically, but that's fine).
logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
return false; return true;
} catch (error) { } catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
logger.log("uploadDeviceSigningKeys advertised no flows!"); logger.log("uploadDeviceSigningKeys advertised no flows!");

View file

@ -295,21 +295,29 @@ export default class DeviceListener {
await crypto.getUserDeviceInfo([cli.getSafeUserId()]); await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
// cross signing isn't enabled - nag to enable it // cross signing isn't enabled - nag to enable it
// There are 2 different toasts for: // There are 3 different toasts for:
if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) { if (!(await crypto.getCrossSigningKeyId()) && (await crypto.userHasCrossSigningKeys())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session) // Toast 1. Cross-signing on account but this device doesn't trust the master key (verify this session)
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
this.checkKeyBackupStatus(); this.checkKeyBackupStatus();
} else { } else {
// No cross-signing or key backup on account (set up encryption) const backupInfo = await this.getKeyBackupInfo();
await cli.waitForClientWellKnown(); if (backupInfo) {
if (isSecureBackupRequired(cli) && isLoggedIn()) { // Toast 2: Key backup is enabled but recovery (4S) is not set up: prompt user to set up recovery.
// If we're meant to set up, and Secure Backup is required, // Since we now enable key backup at registration time, this will be the common case for
// trigger the flow directly without a toast once logged in. // new users.
hideSetupEncryptionToast(); showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
accessSecretStorage();
} else { } else {
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); // Toast 3: No cross-signing or key backup on account (set up encryption)
await cli.waitForClientWellKnown();
if (isSecureBackupRequired(cli) && isLoggedIn()) {
// If we're meant to set up, and Secure Backup is required,
// trigger the flow directly without a toast once logged in.
hideSetupEncryptionToast();
accessSecretStorage();
} else {
showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
}
} }
} }
} }

View file

@ -38,6 +38,9 @@ enum BackupStatus {
/** there is a backup on the server but we are not backing up to it */ /** there is a backup on the server but we are not backing up to it */
SERVER_BACKUP_BUT_DISABLED, SERVER_BACKUP_BUT_DISABLED,
/** Key backup is set up but recovery (4s) is not */
BACKUP_NO_RECOVERY,
/** backup is not set up locally and there is no backup on the server */ /** backup is not set up locally and there is no backup on the server */
NO_BACKUP, NO_BACKUP,
@ -104,7 +107,11 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
} }
if ((await crypto.getActiveSessionBackupVersion()) !== null) { if ((await crypto.getActiveSessionBackupVersion()) !== null) {
this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE }); if (await crypto.isSecretStorageReady()) {
this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE });
} else {
this.setState({ backupStatus: BackupStatus.BACKUP_NO_RECOVERY });
}
return; return;
} }
@ -254,6 +261,7 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
case BackupStatus.NO_BACKUP: case BackupStatus.NO_BACKUP:
case BackupStatus.SERVER_BACKUP_BUT_DISABLED: case BackupStatus.SERVER_BACKUP_BUT_DISABLED:
case BackupStatus.ERROR: case BackupStatus.ERROR:
case BackupStatus.BACKUP_NO_RECOVERY:
return this.renderSetupBackupDialog(); return this.renderSetupBackupDialog();
} }
} }

View file

@ -914,6 +914,9 @@
"warning": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings." "warning": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
}, },
"reset_all_button": "Forgotten or lost all recovery methods? <a>Reset all</a>", "reset_all_button": "Forgotten or lost all recovery methods? <a>Reset all</a>",
"set_up_recovery": "Set up recovery",
"set_up_recovery_later": "Not now",
"set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.",
"set_up_toast_description": "Safeguard against losing access to encrypted messages & data", "set_up_toast_description": "Safeguard against losing access to encrypted messages & data",
"set_up_toast_title": "Set up Secure Backup", "set_up_toast_title": "Set up Secure Backup",
"setup_secure_backup": { "setup_secure_backup": {

View file

@ -116,6 +116,11 @@ export class InitialCryptoSetupStore extends EventEmitter {
try { try {
await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword()); await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword());
const backupInfo = await cryptoApi.getKeyBackupInfo();
if (backupInfo === null) {
await cryptoApi.resetKeyBackup();
}
this.reset(); this.reset();
this.status = "complete"; this.status = "complete";

View file

@ -23,15 +23,19 @@ const getTitle = (kind: Kind): string => {
switch (kind) { switch (kind) {
case Kind.SET_UP_ENCRYPTION: case Kind.SET_UP_ENCRYPTION:
return _t("encryption|set_up_toast_title"); return _t("encryption|set_up_toast_title");
case Kind.SET_UP_RECOVERY:
return _t("encryption|set_up_recovery");
case Kind.VERIFY_THIS_SESSION: case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verify_toast_title"); return _t("encryption|verify_toast_title");
} }
}; };
const getIcon = (kind: Kind): string => { const getIcon = (kind: Kind): string | undefined => {
switch (kind) { switch (kind) {
case Kind.SET_UP_ENCRYPTION: case Kind.SET_UP_ENCRYPTION:
return "secure_backup"; return "secure_backup";
case Kind.SET_UP_RECOVERY:
return undefined;
case Kind.VERIFY_THIS_SESSION: case Kind.VERIFY_THIS_SESSION:
return "verification_warning"; return "verification_warning";
} }
@ -41,15 +45,29 @@ const getSetupCaption = (kind: Kind): string => {
switch (kind) { switch (kind) {
case Kind.SET_UP_ENCRYPTION: case Kind.SET_UP_ENCRYPTION:
return _t("action|continue"); return _t("action|continue");
case Kind.SET_UP_RECOVERY:
return _t("action|continue");
case Kind.VERIFY_THIS_SESSION: case Kind.VERIFY_THIS_SESSION:
return _t("action|verify"); return _t("action|verify");
} }
}; };
const getSecondaryButtonLabel = (kind: Kind): string => {
switch (kind) {
case Kind.SET_UP_RECOVERY:
return _t("encryption|set_up_recovery_later");
case Kind.SET_UP_ENCRYPTION:
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verification|unverified_sessions_toast_reject");
}
};
const getDescription = (kind: Kind): string => { const getDescription = (kind: Kind): string => {
switch (kind) { switch (kind) {
case Kind.SET_UP_ENCRYPTION: case Kind.SET_UP_ENCRYPTION:
return _t("encryption|set_up_toast_description"); return _t("encryption|set_up_toast_description");
case Kind.SET_UP_RECOVERY:
return _t("encryption|set_up_recovery_toast_description");
case Kind.VERIFY_THIS_SESSION: case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verify_toast_description"); return _t("encryption|verify_toast_description");
} }
@ -57,6 +75,7 @@ const getDescription = (kind: Kind): string => {
export enum Kind { export enum Kind {
SET_UP_ENCRYPTION = "set_up_encryption", SET_UP_ENCRYPTION = "set_up_encryption",
SET_UP_RECOVERY = "set_up_recovery",
VERIFY_THIS_SESSION = "verify_this_session", VERIFY_THIS_SESSION = "verify_this_session",
} }
@ -101,9 +120,8 @@ export const showToast = (kind: Kind): void => {
description: getDescription(kind), description: getDescription(kind),
primaryLabel: getSetupCaption(kind), primaryLabel: getSetupCaption(kind),
onPrimaryClick: onAccept, onPrimaryClick: onAccept,
secondaryLabel: _t("encryption|verification|unverified_sessions_toast_reject"), secondaryLabel: getSecondaryButtonLabel(kind),
onSecondaryClick: onReject, onSecondaryClick: onReject,
destructive: "secondary",
}, },
component: GenericToast, component: GenericToast,
priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40, priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40,

View file

@ -352,13 +352,13 @@ describe("DeviceListener", () => {
mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc"); mockCrypto!.getCrossSigningKeyId.mockResolvedValue("abc");
}); });
it("shows set up encryption toast when user has a key backup available", async () => { it("shows set up recovery toast when user has a key backup available", async () => {
// non falsy response // non falsy response
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo); mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
await createAndStart(); await createAndStart();
expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
SetupEncryptionToast.Kind.SET_UP_ENCRYPTION, SetupEncryptionToast.Kind.SET_UP_RECOVERY,
); );
}); });
}); });

View file

@ -1003,7 +1003,9 @@ describe("<MatrixChat />", () => {
userHasCrossSigningKeys: jest.fn().mockResolvedValue(false), userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
// This needs to not finish immediately because we need to test the screen appears // This needs to not finish immediately because we need to test the screen appears
bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise),
resetKeyBackup: jest.fn(),
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
}; };
loginClient.getCrypto.mockReturnValue(mockCrypto as any); loginClient.getCrypto.mockReturnValue(mockCrypto as any);
}); });

View file

@ -42,12 +42,20 @@ describe("LogoutDialog", () => {
expect(rendered.container).toMatchSnapshot(); expect(rendered.container).toMatchSnapshot();
}); });
it("shows a regular dialog if backups are working", async () => { it("shows a regular dialog if backups and recovery are working", async () => {
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1"); mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
mockCrypto.isSecretStorageReady.mockResolvedValue(true);
const rendered = renderComponent(); const rendered = renderComponent();
await rendered.findByText("Are you sure you want to sign out?"); await rendered.findByText("Are you sure you want to sign out?");
}); });
it("prompts user to set up recovery if backups are enabled but recovery isn't", async () => {
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
mockCrypto.isSecretStorageReady.mockResolvedValue(false);
const rendered = renderComponent();
await rendered.findByText("You'll lose access to your encrypted messages");
});
it("Prompts user to connect backup if there is a backup on the server", async () => { it("Prompts user to connect backup if there is a backup on the server", async () => {
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as KeyBackupInfo); mockCrypto.getKeyBackupInfo.mockResolvedValue({} as KeyBackupInfo);
const rendered = renderComponent(); const rendered = renderComponent();

View file

@ -0,0 +1,24 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import ToastContainer from "../../../src/components/structures/ToastContainer";
import { Kind, showToast } from "../../../src/toasts/SetupEncryptionToast";
describe("SetupEncryptionToast", () => {
beforeEach(() => {
render(<ToastContainer />);
});
it("should render the se up recovery toast", async () => {
showToast(Kind.SET_UP_RECOVERY);
await expect(screen.findByText("Set up recovery")).resolves.toBeInTheDocument();
});
});