diff --git a/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx b/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx
index dd06854bbc..3d8dcca428 100644
--- a/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx
+++ b/apps/web/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx
@@ -9,6 +9,7 @@ import React, { type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
import { logger } from "matrix-js-sdk/src/logger";
+import { DecryptionKeyDoesNotMatchError } from "matrix-js-sdk/src/crypto-api";
import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
@@ -92,7 +93,18 @@ export function RecoveryPanelOutOfSync({
if (needsBackupReset) {
await resetKeyBackupAndWait(crypto);
} else if (await matrixClient.isKeyBackupKeyStored()) {
- await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
+ try {
+ await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
+ } catch (error: any) {
+ if (error instanceof DecryptionKeyDoesNotMatchError) {
+ logger.error(
+ "RecoveryPanelOutOfSync: decryption key does not match the public backup. Replacing.",
+ );
+ await resetKeyBackupAndWait(crypto);
+ } else {
+ throw error;
+ }
+ }
}
});
});
diff --git a/apps/web/src/toasts/SetupEncryptionToast.tsx b/apps/web/src/toasts/SetupEncryptionToast.tsx
index aaeb4b6cde..5e9c970c8d 100644
--- a/apps/web/src/toasts/SetupEncryptionToast.tsx
+++ b/apps/web/src/toasts/SetupEncryptionToast.tsx
@@ -12,6 +12,7 @@ import { KeyIcon, ErrorSolidIcon, SettingsSolidIcon } from "@vector-im/compound-
import { type ComponentType } from "react";
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
import { logger } from "matrix-js-sdk/src/logger";
+import { DecryptionKeyDoesNotMatchError } from "matrix-js-sdk/src/crypto-api";
import Modal from "../Modal";
import { _t } from "../languageHandler";
@@ -207,7 +208,18 @@ export const showToast = (state: DeviceStateForToast): void => {
if (needsBackupReset) {
await resetKeyBackupAndWait(crypto);
} else if (await matrixClient.isKeyBackupKeyStored()) {
- await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
+ try {
+ await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
+ } catch (error: any) {
+ if (error instanceof DecryptionKeyDoesNotMatchError) {
+ myLogger.error(
+ "SetupEncryptionToast: decryption key does not match the public backup. Replacing.",
+ );
+ await resetKeyBackupAndWait(crypto);
+ } else {
+ throw error;
+ }
+ }
}
});
});
diff --git a/apps/web/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx b/apps/web/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx
index 222df74ec1..8839382e8d 100644
--- a/apps/web/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx
+++ b/apps/web/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx
@@ -10,6 +10,8 @@ import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
+import { type SecretStorageKeyDescriptionAesV1 } from "matrix-js-sdk/src/secret-storage";
+import { DecryptionKeyDoesNotMatchError } from "matrix-js-sdk/src/crypto-api";
import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync";
import { AccessCancelledError, accessSecretStorage } from "../../../../../../src/SecurityManager";
@@ -33,7 +35,6 @@ describe("", () => {
onForgotRecoveryKey = jest.fn(),
onAccessSecretStorageFailed = jest.fn(),
) {
- matrixClient = createTestClient();
return render(
", () => {
);
}
+ beforeEach(() => {
+ matrixClient = createTestClient();
+ });
+
afterEach(() => {
jest.clearAllMocks();
});
@@ -63,7 +68,7 @@ describe("", () => {
expect(onForgotRecoveryKey).toHaveBeenCalled();
});
- it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => {
+ it("should load backup decryption key and call onFinish when 'Enter recovery key' is clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
const user = userEvent.setup();
@@ -71,6 +76,8 @@ describe("", () => {
return await func();
});
+ mocked(matrixClient.isKeyBackupKeyStored).mockResolvedValue(fakeKeyBackupKey());
+
const onFinish = jest.fn();
renderComponent(onFinish);
@@ -78,7 +85,9 @@ describe("", () => {
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
+ expect(matrixClient.isKeyBackupKeyStored).toHaveBeenCalled();
expect(matrixClient.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled();
+ expect(matrixClient.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalled();
});
it("should reset key backup if needed", async () => {
@@ -99,6 +108,32 @@ describe("", () => {
expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
});
+ it("should reset key backup if decryption key from secret storage does not match backup", async () => {
+ jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
+
+ const user = userEvent.setup();
+ mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => func());
+ mocked(matrixClient.isKeyBackupKeyStored).mockResolvedValue(fakeKeyBackupKey());
+
+ // Given we will fail to load a private key because it doesn't match the
+ // latest backup public key
+ mocked(matrixClient.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).mockRejectedValue(
+ new DecryptionKeyDoesNotMatchError("key no matchy"),
+ );
+
+ const onFinish = jest.fn();
+ renderComponent(onFinish);
+
+ // When we enter the recovery key
+ await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
+ expect(accessSecretStorage).toHaveBeenCalled();
+ expect(onFinish).toHaveBeenCalled();
+
+ // Then we reset backup after attempting to load the key
+ expect(matrixClient.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalled();
+ expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
+ });
+
it("should call onAccessSecretStorageFailed on failure", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
@@ -115,6 +150,28 @@ describe("", () => {
expect(onAccessSecretStorageFailed).toHaveBeenCalled();
});
+ it("should call onAccessSecretStorageFailed when loadSessionBackupPrivateKeyFromSecretStorage fails", async () => {
+ jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
+
+ const user = userEvent.setup();
+ mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => func());
+
+ // Given we will fail to load a private key because of some unexpected error
+ mocked(matrixClient.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).mockRejectedValue(
+ new Error("Unexpected error"),
+ );
+ mocked(matrixClient.isKeyBackupKeyStored).mockResolvedValue(fakeKeyBackupKey());
+
+ const onAccessSecretStorageFailed = jest.fn();
+ renderComponent(jest.fn(), jest.fn(), onAccessSecretStorageFailed);
+
+ // When we enter the recovery key
+ await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
+
+ // Then we handle the error in onAccessSecretStorageFailed
+ expect(onAccessSecretStorageFailed).toHaveBeenCalled();
+ });
+
it("should not call onAccessSecretStorageFailed when cancelled", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
@@ -133,3 +190,23 @@ describe("", () => {
expect(onAccessSecretStorageFailed).not.toHaveBeenCalled();
});
});
+
+/**
+ * Just enough of a key backup key to persuade RecoveryPanelOutOfSync that we
+ * don't need to reset backup.
+ */
+function fakeKeyBackupKey(): Record {
+ return {
+ x: {
+ iv: "x",
+ mac: "y",
+ name: "n",
+ algorithm: "a",
+ passphrase: {
+ algorithm: "m.pbkdf2",
+ iterations: 1,
+ salt: "s",
+ },
+ },
+ };
+}
diff --git a/apps/web/test/unit-tests/toasts/SetupEncryptionToast-test.tsx b/apps/web/test/unit-tests/toasts/SetupEncryptionToast-test.tsx
index 8bae6689ec..6a589d32ef 100644
--- a/apps/web/test/unit-tests/toasts/SetupEncryptionToast-test.tsx
+++ b/apps/web/test/unit-tests/toasts/SetupEncryptionToast-test.tsx
@@ -11,7 +11,8 @@ import { act, render, screen } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
-import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
+import { type CryptoApi, DecryptionKeyDoesNotMatchError } from "matrix-js-sdk/src/crypto-api";
+import { type SecretStorageKeyDescriptionAesV1 } from "matrix-js-sdk/src/secret-storage";
import * as SecurityManager from "../../../src/SecurityManager";
import ToastContainer from "../../../src/components/structures/ToastContainer";
@@ -114,6 +115,30 @@ describe("SetupEncryptionToast", () => {
expect(client.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalled();
});
+ it("should reset key backup if decryption key does not match backup", async () => {
+ showToast("key_storage_out_of_sync");
+
+ const crypto = client.getCrypto()!;
+
+ jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(async (func = async (): Promise => {}) => func());
+
+ // Given we throw when trying to load the backup decrption key
+ mocked(crypto.loadSessionBackupPrivateKeyFromSecretStorage).mockRejectedValue(
+ new DecryptionKeyDoesNotMatchError("it key no match"),
+ );
+
+ jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
+ client.isKeyBackupKeyStored.mockResolvedValue({});
+
+ // When we enter our recovery key
+ const user = userEvent.setup();
+ await user.click(await screen.findByText("Enter recovery key"));
+
+ // Then we should reset the key backup because we caught the
+ // DecryptionKeyDoesNotMatchError.
+ expect(client.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
+ });
+
it("should open settings to the reset flow when 'forgot recovery key' clicked and identity reset needed", async () => {
act(() => showToast("key_storage_out_of_sync"));
@@ -190,6 +215,38 @@ describe("SetupEncryptionToast", () => {
});
});
+ it("should go to change recovery key when recovering fails inside loadSessionBackup...", async () => {
+ jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation(async (func = async (): Promise => {}) => func());
+
+ jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue(
+ true,
+ );
+ jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
+ mocked(client.isKeyBackupKeyStored).mockResolvedValue(fakeKeyBackupKey());
+
+ // Given when we try to load from backup we get an unexpected error
+ mocked(client.getCrypto()!.loadSessionBackupPrivateKeyFromSecretStorage).mockRejectedValue(
+ new Error("Unexpected error"),
+ );
+
+ act(() => showToast("key_storage_out_of_sync"));
+
+ // When we start to entry recovery key
+ const user = userEvent.setup();
+ await user.click(await screen.findByText("Enter recovery key"));
+
+ // Then we handle the error and jump to the Encryption Settings.
+ //
+ // Note: It seems reasonable to ask the user to reset their identity
+ // in this case, but we're not actually sure what happened, so it
+ // may not be the right response.
+ expect(dis.dispatch).toHaveBeenCalledWith({
+ action: "view_user_settings",
+ initialTabId: "USER_ENCRYPTION_TAB",
+ props: { initialEncryptionState: "reset_identity_sync_failed" },
+ });
+ });
+
it("should dismiss the toast when the close button is clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup");
@@ -200,6 +257,26 @@ describe("SetupEncryptionToast", () => {
expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled();
});
+
+ /**
+ * Just enough of a key backup key to persuade SetupEncryptionToast that we
+ * don't need to reset backup.
+ */
+ function fakeKeyBackupKey(): Record {
+ return {
+ x: {
+ iv: "x",
+ mac: "y",
+ name: "n",
+ algorithm: "a",
+ passphrase: {
+ algorithm: "m.pbkdf2",
+ iterations: 1,
+ salt: "s",
+ },
+ },
+ };
+ }
});
describe("Turn on key storage", () => {