diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 71b7f622fd..470491ecd5 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -36,6 +36,7 @@ import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityMan import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { isLoggedIn } from './components/structures/MatrixChat'; import { ActionPayload } from "./dispatcher/payloads"; +import { Action } from "./dispatcher/actions"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -48,6 +49,7 @@ export default class DeviceListener { // cache of the key backup info private keyBackupInfo: object = null; private keyBackupFetchedAt: number = null; + private keyBackupStatusChecked = false; // We keep a list of our own device IDs so we can batch ones that were already // there the last time the app launched into a single toast, but display new // ones in their own toasts. @@ -92,6 +94,7 @@ export default class DeviceListener { this.dismissedThisDeviceToast = false; this.keyBackupInfo = null; this.keyBackupFetchedAt = null; + this.keyBackupStatusChecked = false; this.ourDeviceIdsAtStart = null; this.displayingToastsForDeviceIds = new Set(); } @@ -227,6 +230,8 @@ export default class DeviceListener { if (this.dismissedThisDeviceToast || allSystemsReady) { hideSetupEncryptionToast(); + + this.checkKeyBackupStatus(); } else if (this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading await cli.downloadKeys([cli.getUserId()]); @@ -238,6 +243,7 @@ export default class DeviceListener { ) { // Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); + this.checkKeyBackupStatus(); } else { const backupInfo = await this.getKeyBackupInfo(); if (backupInfo) { @@ -312,4 +318,17 @@ export default class DeviceListener { this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; } + + private checkKeyBackupStatus = async () => { + if (this.keyBackupStatusChecked) { + return; + } + // returns null when key backup status hasn't finished being checked + const isKeyBackupEnabled = MatrixClientPeg.get().getKeyBackupEnabled(); + this.keyBackupStatusChecked = isKeyBackupEnabled !== null; + + if (isKeyBackupEnabled === false) { + dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); + } + }; } diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 17bbc3b73a..c92d887524 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -218,4 +218,10 @@ export enum Action { * Payload: none */ AnonymousAnalyticsReject = "anonymous_analytics_reject", + + /** + * Fires after crypto is setup if key backup is not enabled + * Used to trigger auto rageshakes when configured + */ + ReportKeyBackupNotEnabled = "report_key_backup_not_enabled", } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1652aad003..e50bd52445 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -960,6 +960,7 @@ "Developer mode": "Developer mode", "Automatically send debug logs on any error": "Automatically send debug logs on any error", "Automatically send debug logs on decryption errors": "Automatically send debug logs on decryption errors", + "Automatically send debug logs when key backup is not functioning": "Automatically send debug logs when key backup is not functioning", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index d340012939..004cbd2174 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -891,6 +891,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new ReloadOnChangeController(), }, + "automaticKeyBackNotEnabledReporting": { + displayName: _td("Automatically send debug logs when key backup is not functioning"), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + default: false, + }, [UIFeature.RoomHistorySettings]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/src/stores/AutoRageshakeStore.ts b/src/stores/AutoRageshakeStore.ts index 3dcd8fa119..320a1e3817 100644 --- a/src/stores/AutoRageshakeStore.ts +++ b/src/stores/AutoRageshakeStore.ts @@ -24,6 +24,7 @@ import defaultDispatcher from '../dispatcher/dispatcher'; import { AsyncStoreWithClient } from './AsyncStoreWithClient'; import { ActionPayload } from '../dispatcher/payloads'; import SettingsStore from "../settings/SettingsStore"; +import { Action } from "../dispatcher/actions"; // Minimum interval of 1 minute between reports const RAGESHAKE_INTERVAL = 60000; @@ -62,7 +63,10 @@ export default class AutoRageshakeStore extends AsyncStoreWithClient { } protected async onAction(payload: ActionPayload) { - // we don't actually do anything here + switch (payload.action) { + case Action.ReportKeyBackupNotEnabled: + this.onReportKeyBackupNotEnabled(); + } } protected async onReady() { @@ -152,6 +156,16 @@ export default class AutoRageshakeStore extends AsyncStoreWithClient { }); } } + + private async onReportKeyBackupNotEnabled(): Promise { + if (!SettingsStore.getValue("automaticKeyBackNotEnabledReporting")) return; + + await sendBugReport(SdkConfig.get().bug_report_endpoint_url, { + userText: `Auto-reporting key backup not enabled`, + sendLogs: true, + labels: ["web", Action.ReportKeyBackupNotEnabled], + }); + } } window.mxAutoRageshakeStore = AutoRageshakeStore.instance; diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts new file mode 100644 index 0000000000..e591899c55 --- /dev/null +++ b/test/DeviceListener-test.ts @@ -0,0 +1,244 @@ + +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from "events"; +import { mocked } from "jest-mock"; +import { Room } from "matrix-js-sdk"; + +import './skinned-sdk'; +import DeviceListener from "../src/DeviceListener"; +import { MatrixClientPeg } from "../src/MatrixClientPeg"; +import * as SetupEncryptionToast from "../src/toasts/SetupEncryptionToast"; +import * as UnverifiedSessionToast from "../src/toasts/UnverifiedSessionToast"; +import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessionsToast"; +import { isSecretStorageBeingAccessed } from "../src/SecurityManager"; +import dis from "../src/dispatcher/dispatcher"; +import { Action } from "../src/dispatcher/actions"; + +// don't litter test console with logs +jest.mock("matrix-js-sdk/src/logger"); + +jest.mock("../src/dispatcher/dispatcher", () => ({ + dispatch: jest.fn(), + register: jest.fn(), +})); + +jest.mock("../src/SecurityManager", () => ({ + isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), +})); + +class MockClient extends EventEmitter { + getUserId = jest.fn(); + getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); + getRooms = jest.fn().mockReturnValue([]); + doesServerSupportUnstableFeature = jest.fn().mockResolvedValue(true); + isCrossSigningReady = jest.fn().mockResolvedValue(true); + isSecretStorageReady = jest.fn().mockResolvedValue(true); + isCryptoEnabled = jest.fn().mockReturnValue(true); + isInitialSyncComplete = jest.fn().mockReturnValue(true); + getKeyBackupEnabled = jest.fn(); + getStoredDevicesForUser = jest.fn().mockReturnValue([]); + getCrossSigningId = jest.fn(); + getStoredCrossSigningForUser = jest.fn(); + waitForClientWellKnown = jest.fn(); + downloadKeys = jest.fn(); + isRoomEncrypted = jest.fn(); + getClientWellKnown = jest.fn(); +} +const mockDispatcher = mocked(dis); +const flushPromises = async () => await new Promise(process.nextTick); + +describe('DeviceListener', () => { + let mockClient; + + // spy on various toasts' hide and show functions + // easier than mocking + jest.spyOn(SetupEncryptionToast, 'showToast'); + jest.spyOn(SetupEncryptionToast, 'hideToast'); + jest.spyOn(BulkUnverifiedSessionsToast, 'showToast'); + jest.spyOn(BulkUnverifiedSessionsToast, 'hideToast'); + jest.spyOn(UnverifiedSessionToast, 'showToast'); + jest.spyOn(UnverifiedSessionToast, 'hideToast'); + + beforeEach(() => { + jest.resetAllMocks(); + mockClient = new MockClient(); + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); + }); + + const createAndStart = async (): Promise => { + const instance = new DeviceListener(); + instance.start(); + await flushPromises(); + return instance; + }; + + describe('recheck', () => { + it('does nothing when cross signing feature is not supported', async () => { + mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); + await createAndStart(); + + expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); + }); + it('does nothing when crypto is not enabled', async () => { + mockClient.isCryptoEnabled.mockReturnValue(false); + await createAndStart(); + + expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); + }); + it('does nothing when initial sync is not complete', async () => { + mockClient.isInitialSyncComplete.mockReturnValue(false); + await createAndStart(); + + expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); + }); + + describe('set up encryption', () => { + const rooms = [ + { roomId: '!room1' }, + { roomId: '!room2' }, + ] as unknown as Room[]; + + beforeEach(() => { + mockClient.isCrossSigningReady.mockResolvedValue(false); + mockClient.isSecretStorageReady.mockResolvedValue(false); + mockClient.getRooms.mockReturnValue(rooms); + mockClient.isRoomEncrypted.mockReturnValue(true); + }); + + it('hides setup encryption toast when cross signing and secret storage are ready', async () => { + mockClient.isCrossSigningReady.mockResolvedValue(true); + mockClient.isSecretStorageReady.mockResolvedValue(true); + await createAndStart(); + expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); + }); + + it('hides setup encryption toast when it is dismissed', async () => { + const instance = await createAndStart(); + instance.dismissEncryptionSetup(); + await flushPromises(); + expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); + }); + + it('does not do any checks or show any toasts when secret storage is being accessed', async () => { + mocked(isSecretStorageBeingAccessed).mockReturnValue(true); + await createAndStart(); + + expect(mockClient.downloadKeys).not.toHaveBeenCalled(); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); + }); + + it('does not do any checks or show any toasts when no rooms are encrypted', async () => { + mockClient.isRoomEncrypted.mockReturnValue(false); + await createAndStart(); + + expect(mockClient.downloadKeys).not.toHaveBeenCalled(); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); + }); + + describe('when user does not have a cross signing id on this device', () => { + beforeEach(() => { + mockClient.getCrossSigningId.mockReturnValue(undefined); + }); + + it('shows verify session toast when account has cross signing', async () => { + mockClient.getStoredCrossSigningForUser.mockReturnValue(true); + await createAndStart(); + + expect(mockClient.downloadKeys).toHaveBeenCalled(); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( + SetupEncryptionToast.Kind.VERIFY_THIS_SESSION); + }); + + it('checks key backup status when when account has cross signing', async () => { + mockClient.getCrossSigningId.mockReturnValue(undefined); + mockClient.getStoredCrossSigningForUser.mockReturnValue(true); + await createAndStart(); + + expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + }); + }); + + describe('when user does have a cross signing id on this device', () => { + beforeEach(() => { + mockClient.getCrossSigningId.mockReturnValue('abc'); + }); + + it('shows upgrade encryption toast when user has a key backup available', async () => { + // non falsy response + mockClient.getKeyBackupVersion.mockResolvedValue({}); + await createAndStart(); + + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( + SetupEncryptionToast.Kind.UPGRADE_ENCRYPTION); + }); + }); + }); + + describe('key backup status', () => { + it('checks keybackup status when cross signing and secret storage are ready', async () => { + // default mocks set cross signing and secret storage to ready + await createAndStart(); + expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('checks keybackup status when setup encryption toast has been dismissed', async () => { + mockClient.isCrossSigningReady.mockResolvedValue(false); + const instance = await createAndStart(); + + instance.dismissEncryptionSetup(); + await flushPromises(); + + expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + }); + + it('does not dispatch keybackup event when key backup check is not finished', async () => { + // returns null when key backup status hasn't finished being checked + mockClient.getKeyBackupEnabled.mockReturnValue(null); + await createAndStart(); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches keybackup event when key backup is not enabled', async () => { + mockClient.getKeyBackupEnabled.mockReturnValue(false); + await createAndStart(); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ReportKeyBackupNotEnabled }); + }); + + it('does not check key backup status again after check is complete', async () => { + mockClient.getKeyBackupEnabled.mockReturnValue(null); + const instance = await createAndStart(); + expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + + // keyback check now complete + mockClient.getKeyBackupEnabled.mockReturnValue(true); + + // trigger a recheck + instance.dismissEncryptionSetup(); + await flushPromises(); + expect(mockClient.getKeyBackupEnabled).toHaveBeenCalledTimes(2); + + // trigger another recheck + instance.dismissEncryptionSetup(); + await flushPromises(); + // not called again, check was complete last time + expect(mockClient.getKeyBackupEnabled).toHaveBeenCalledTimes(2); + }); + }); + }); +});