Reset key storage if restoring from Recovery encounters the wrong decryption key (#32668)

* Set up the MatrixClient before each RecoveryPanelOutOfSync test

Without this, we can't override MatrixClient methods until we've called
`renderComponent`, which is awkward.

* Actually test that we load the decryption key in RecoveryPanelOutOfSync

It turns out the existing test didn't actually go down the expected code
path and call loadSessionBackupPrivateKeyFromSecretStorage.

* Reset key storage if restoring from Recovery encounters the wrong decryption key

Fixes https://github.com/element-hq/element-web/issues/31793

Depends on https://github.com/matrix-org/matrix-js-sdk/pull/5202

When we try to load the key storage decryption key from Recovery, but we
find that it does not match the public key of the current key storage
backup, create a new key storage backup.
This commit is contained in:
Andy Balaam
2026-03-05 10:47:50 +00:00
committed by GitHub
parent 9035da48a2
commit 1c2441bc76
4 changed files with 183 additions and 5 deletions
@@ -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("<RecoveyPanelOutOfSync />", () => {
onForgotRecoveryKey = jest.fn(),
onAccessSecretStorageFailed = jest.fn(),
) {
matrixClient = createTestClient();
return render(
<RecoveryPanelOutOfSync
onFinish={onFinish}
@@ -44,6 +45,10 @@ describe("<RecoveyPanelOutOfSync />", () => {
);
}
beforeEach(() => {
matrixClient = createTestClient();
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -63,7 +68,7 @@ describe("<RecoveyPanelOutOfSync />", () => {
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("<RecoveyPanelOutOfSync />", () => {
return await func();
});
mocked(matrixClient.isKeyBackupKeyStored).mockResolvedValue(fakeKeyBackupKey());
const onFinish = jest.fn();
renderComponent(onFinish);
@@ -78,7 +85,9 @@ describe("<RecoveyPanelOutOfSync />", () => {
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("<RecoveyPanelOutOfSync />", () => {
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<void> => {}) => 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("<RecoveyPanelOutOfSync />", () => {
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<void> => {}) => 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("<RecoveyPanelOutOfSync />", () => {
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<string, SecretStorageKeyDescriptionAesV1> {
return {
x: {
iv: "x",
mac: "y",
name: "n",
algorithm: "a",
passphrase: {
algorithm: "m.pbkdf2",
iterations: 1,
salt: "s",
},
},
};
}