diff --git a/.github/workflows/build_test.yaml b/.github/workflows/build_test.yaml index 36478bf..b19e9cd 100644 --- a/.github/workflows/build_test.yaml +++ b/.github/workflows/build_test.yaml @@ -67,7 +67,7 @@ jobs: - name: Run tests uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a - timeout-minutes: 5 + timeout-minutes: 20 with: run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }} env: diff --git a/playwright/e2e/launch/launch.spec.ts b/playwright/e2e/launch/launch.spec.ts index fb1d371..6bd591a 100644 --- a/playwright/e2e/launch/launch.spec.ts +++ b/playwright/e2e/launch/launch.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { platform } from "node:os"; +import keytar from "keytar-forked"; import { test, expect } from "../../element-desktop-test.js"; @@ -17,6 +17,7 @@ declare global { supportsEventIndexing(): Promise; } | undefined; + getPickleKey(userId: string, deviceId: string): Promise; createPickleKey(userId: string, deviceId: string): Promise; } @@ -48,14 +49,46 @@ test.describe("App launch", () => { ).resolves.toBeTruthy(); }); - test("should launch and render the welcome view successfully and support keytar", async ({ page }) => { - test.skip(platform() === "linux", "This test does not yet support Linux"); + test.describe("safeStorage", () => { + const userId = "@user:server"; + const deviceId = "ABCDEF"; - await expect( - page.evaluate(async () => { - return await window.mxPlatformPeg.get().createPickleKey("@user:server", "ABCDEF"); - }), - ).resolves.not.toBeNull(); + test("should be supported", async ({ page }) => { + await expect( + page.evaluate( + ([userId, deviceId]) => window.mxPlatformPeg.get().createPickleKey(userId, deviceId), + [userId, deviceId], + ), + ).resolves.not.toBeNull(); + }); + + test.describe("migrate from keytar", () => { + test.skip( + process.env.GITHUB_ACTIONS && ["linux", "darwin"].includes(process.platform), + "GitHub Actions hosted runner are not a compatible environment for this test", + ); + + const pickleKey = "DEADBEEF1234"; + const keytarService = "element.io"; + const keytarKey = `${userId}|${deviceId}`; + + test.beforeAll(async () => { + await keytar.setPassword(keytarService, keytarKey, pickleKey); + await expect(keytar.getPassword(keytarService, keytarKey)).resolves.toBe(pickleKey); + }); + test.afterAll(async () => { + await keytar.deletePassword(keytarService, keytarKey); + }); + + test("should migrate successfully", async ({ page }) => { + await expect( + page.evaluate( + ([userId, deviceId]) => window.mxPlatformPeg.get().getPickleKey(userId, deviceId), + [userId, deviceId], + ), + ).resolves.toBe(pickleKey); + }); + }); }); test.describe("--no-update", () => { diff --git a/playwright/element-desktop-test.ts b/playwright/element-desktop-test.ts index 3d8d4f5..1f53ab6 100644 --- a/playwright/element-desktop-test.ts +++ b/playwright/element-desktop-test.ts @@ -62,12 +62,21 @@ export const test = base.extend({ // eslint-disable-next-line no-empty-pattern tmpDir: async ({}, use) => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-")); - console.log("Using temp profile directory: ", tmpDir); await use(tmpDir); await fs.rm(tmpDir, { recursive: true }); }, app: async ({ tmpDir, extraEnv, extraArgs, stdout, stderr }, use) => { - const args = ["--profile-dir", tmpDir]; + const args = ["--profile-dir", tmpDir, ...extraArgs]; + + if (process.env.GITHUB_ACTIONS) { + if (process.platform === "linux") { + // GitHub Actions hosted runner lacks dbus and a compatible keyring, so we need to force plaintext storage + args.push("--storage-mode", "force-plaintext"); + } else if (process.platform === "darwin") { + // GitHub Actions hosted runner has no working default keychain, so allow plaintext storage + args.push("--storage-mode", "allow-plaintext"); + } + } const executablePath = process.env["ELEMENT_DESKTOP_EXECUTABLE"]; if (!executablePath) { @@ -75,13 +84,15 @@ export const test = base.extend({ args.unshift(path.join(__dirname, "..", "lib", "electron-main.js")); } + console.log(`Launching '${executablePath}' with args ${args.join(" ")}`); + const app = await electron.launch({ env: { ...process.env, ...extraEnv, }, executablePath, - args: [...args, ...extraArgs], + args, }); app.process().stdout.pipe(stdout).pipe(process.stdout); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 99e6def..bfce59b 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -8,7 +8,7 @@ "module": "node16", "sourceMap": false, "strict": true, - "lib": ["es2020"] + "lib": ["es2021"] }, "include": ["../src/@types", "./**/*.ts"] } diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index d3b13be..68514bc 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. import { type BrowserWindow } from "electron"; -import type Store from "electron-store"; import type AutoLaunch from "auto-launch"; import { type AppLocalization } from "../language-helper.js"; @@ -24,13 +23,5 @@ declare global { icon_path: string; brand: string; }; - var store: Store<{ - warnBeforeExit?: boolean; - minimizeToTray?: boolean; - spellCheckerEnabled?: boolean; - autoHideMenuBar?: boolean; - locale?: string | string[]; - disableHardwareAcceleration?: boolean; - }>; } /* eslint-enable no-var */ diff --git a/src/electron-main.ts b/src/electron-main.ts index fddee60..6c91eb1 100644 --- a/src/electron-main.ts +++ b/src/electron-main.ts @@ -16,7 +16,6 @@ import * as Sentry from "@sentry/electron/main"; import AutoLaunch from "auto-launch"; import path, { dirname } from "node:path"; import windowStateKeeper from "electron-window-state"; -import Store from "electron-store"; import fs, { promises as afs } from "node:fs"; import { URL, fileURLToPath } from "node:url"; import minimist from "minimist"; @@ -25,6 +24,7 @@ import "./ipc.js"; import "./seshat.js"; import "./settings.js"; import * as tray from "./tray.js"; +import Store from "./store.js"; import { buildMenuTemplate } from "./vectormenu.js"; import webContentsHandler from "./webcontents-handler.js"; import * as updater from "./updater.js"; @@ -273,8 +273,6 @@ async function moveAutoLauncher(): Promise { } } -global.store = new Store({ name: "electron-config" }); - global.appQuitting = false; const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ @@ -284,32 +282,6 @@ const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ platform === "darwin" && input.meta && !input.control && input.key.toUpperCase() === "Q", ]; -const warnBeforeExit = (event: Event, input: Input): void => { - const shouldWarnBeforeExit = global.store.get("warnBeforeExit", true); - const exitShortcutPressed = - input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform)); - - if (shouldWarnBeforeExit && exitShortcutPressed && global.mainWindow) { - const shouldCancelCloseRequest = - dialog.showMessageBoxSync(global.mainWindow, { - type: "question", - buttons: [ - _t("action|cancel"), - _t("action|close_brand", { - brand: global.vectorConfig.brand || "Element", - }), - ], - message: _t("confirm_quit"), - defaultId: 1, - cancelId: 0, - }) === 0; - - if (shouldCancelCloseRequest) { - event.preventDefault(); - } - } -}; - void configureSentry(); // handle uncaught errors otherwise it displays @@ -366,13 +338,17 @@ app.enableSandbox(); // We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704 app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService"); +const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions + // Disable hardware acceleration if the setting has been set. -if (global.store.get("disableHardwareAcceleration", false) === true) { +if (store.get("disableHardwareAcceleration")) { console.log("Disabling hardware acceleration."); app.disableHardwareAcceleration(); } app.on("ready", async () => { + console.debug("Reached Electron ready state"); + let asarPath: string; try { @@ -462,12 +438,27 @@ app.on("ready", async () => { console.log("No update_base_url is defined: auto update is disabled"); } + // Set up i18n before loading storage as we need translations for dialogs + global.appLocalization = new AppLocalization({ + components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())], + store, + }); + + try { + console.debug("Ensuring storage is ready"); + await store.safeStorageReady(); + } catch (e) { + console.error(e); + app.exit(1); + } + // Load the previous window state with fallback to defaults const mainWindowState = windowStateKeeper({ defaultWidth: 1024, defaultHeight: 768, }); + console.debug("Opening main window"); const preloadScript = path.normalize(`${__dirname}/preload.cjs`); global.mainWindow = new BrowserWindow({ // https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do @@ -478,7 +469,7 @@ app.on("ready", async () => { icon: global.trayConfig.icon_path, show: false, - autoHideMenuBar: global.store.get("autoHideMenuBar", true), + autoHideMenuBar: store.get("autoHideMenuBar"), x: mainWindowState.x, y: mainWindowState.y, @@ -500,10 +491,10 @@ app.on("ready", async () => { // Handle spellchecker // For some reason spellCheckerEnabled isn't persisted, so we have to use the store here - global.mainWindow.webContents.session.setSpellCheckerEnabled(global.store.get("spellCheckerEnabled", true)); + global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true)); // Create trayIcon icon - if (global.store.get("minimizeToTray", true)) tray.create(global.trayConfig); + if (store.get("minimizeToTray")) tray.create(global.trayConfig); global.mainWindow.once("ready-to-show", () => { if (!global.mainWindow) return; @@ -517,7 +508,31 @@ app.on("ready", async () => { } }); - global.mainWindow.webContents.on("before-input-event", warnBeforeExit); + global.mainWindow.webContents.on("before-input-event", (event: Event, input: Input): void => { + const shouldWarnBeforeExit = store.get("warnBeforeExit", true); + const exitShortcutPressed = + input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform)); + + if (shouldWarnBeforeExit && exitShortcutPressed && global.mainWindow) { + const shouldCancelCloseRequest = + dialog.showMessageBoxSync(global.mainWindow, { + type: "question", + buttons: [ + _t("action|cancel"), + _t("action|close_brand", { + brand: global.vectorConfig.brand || "Element", + }), + ], + message: _t("confirm_quit"), + defaultId: 1, + cancelId: 0, + }) === 0; + + if (shouldCancelCloseRequest) { + event.preventDefault(); + } + } + }); global.mainWindow.on("closed", () => { global.mainWindow = null; @@ -555,11 +570,6 @@ app.on("ready", async () => { webContentsHandler(global.mainWindow.webContents); - global.appLocalization = new AppLocalization({ - store: global.store, - components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())], - }); - session.defaultSession.setDisplayMediaRequestHandler((_, callback) => { global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker"); setDisplayMediaCallback(callback); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2bdb0bc..4259f3d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -22,7 +22,9 @@ "about": "About", "brand_help": "%(brand)s Help", "help": "Help", - "preferences": "Preferences" + "no": "No", + "preferences": "Preferences", + "yes": "Yes" }, "confirm_quit": "Are you sure you want to quit?", "edit_menu": { @@ -49,6 +51,20 @@ "save_image_as_error_description": "The image failed to save", "save_image_as_error_title": "Failed to save image" }, + "store": { + "error": { + "backend_changed": "Clear data and reload?", + "backend_changed_detail": "Unable to access secret from system keyring, it appears to have changed.", + "backend_changed_title": "Failed to load database", + "unknown_backend_override": "Your system has an unsupported keyring meaning the database cannot be opened.", + "unknown_backend_override_details": "Please check the logs for more details.", + "unknown_backend_override_title": "Failed to load database", + "unsupported_keyring": "Your system has an unsupported keyring meaning the database cannot be opened.", + "unsupported_keyring_cta": "Use weaker encryption", + "unsupported_keyring_detail": "Electron's keyring detection did not find a supported backend, you may be able to convince it to use one on your system anyway, see %(link)s.", + "unsupported_keyring_title": "System unsupported" + } + }, "view_menu": { "actual_size": "Actual Size", "toggle_developer_tools": "Toggle Developer Tools", diff --git a/src/ipc.ts b/src/ipc.ts index c2f6371..6cbd49e 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -6,14 +6,13 @@ Please see LICENSE files in the repository root for full details. */ import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron"; -import { relaunchApp } from "@standardnotes/electron-clear-data"; -import keytar from "keytar-forked"; import IpcMainEvent = Electron.IpcMainEvent; import { recordSSOSession } from "./protocol.js"; import { randomArray } from "./utils.js"; import { Settings } from "./settings.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; +import Store, { clearDataAndRelaunch } from "./store.js"; ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void { if (process.platform !== "win32") { @@ -61,7 +60,8 @@ ipcMain.on("app_onAction", function (_ev: IpcMainEvent, payload) { }); ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { - if (!global.mainWindow) return; + const store = Store.instance; + if (!global.mainWindow || !store) return; const args = payload.args || []; let ret: any; @@ -113,11 +113,11 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { if (typeof args[0] !== "boolean") return; global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]); - global.store.set("spellCheckerEnabled", args[0]); + store.set("spellCheckerEnabled", args[0]); break; case "getSpellCheckEnabled": - ret = global.store.get("spellCheckerEnabled", true); + ret = store.get("spellCheckerEnabled"); break; case "setSpellCheckLanguages": @@ -141,12 +141,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "getPickleKey": try { - ret = await keytar.getPassword("element.io", `${args[0]}|${args[1]}`); - // migrate from riot.im (remove once we think there will no longer be - // logins from the time of riot.im) - if (ret === null) { - ret = await keytar.getPassword("riot.im", `${args[0]}|${args[1]}`); - } + ret = await store.getSecret(`${args[0]}|${args[1]}`); } catch { // if an error is thrown (e.g. keytar can't connect to the keychain), // then return null, which means the default pickle key will be used @@ -157,9 +152,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "createPickleKey": try { const pickleKey = await randomArray(32); - // We purposefully throw if keytar is not available so the caller can handle it - // rather than sending them a pickle key we did not store on their behalf. - await keytar!.setPassword("element.io", `${args[0]}|${args[1]}`, pickleKey); + await store.setSecret(`${args[0]}|${args[1]}`, pickleKey); ret = pickleKey; } catch (e) { console.error("Failed to create pickle key", e); @@ -169,11 +162,10 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "destroyPickleKey": try { - await keytar.deletePassword("element.io", `${args[0]}|${args[1]}`); - // migrate from riot.im (remove once we think there will no longer be - // logins from the time of riot.im) - await keytar.deletePassword("riot.im", `${args[0]}|${args[1]}`); - } catch {} + await store.deleteSecret(`${args[0]}|${args[1]}`); + } catch (e) { + console.error("Failed to destroy pickle key", e); + } break; case "getDesktopCapturerSources": ret = (await desktopCapturer.getSources(args[0])).map((source) => ({ @@ -189,10 +181,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { break; case "clearStorage": - global.store.clear(); - global.mainWindow.webContents.session.flushStorageData(); - await global.mainWindow.webContents.session.clearStorageData(); - relaunchApp(); + await clearDataAndRelaunch(); return; // the app is about to stop, we don't need to reply to the IPC case "breadcrumbs": { diff --git a/src/language-helper.ts b/src/language-helper.ts index 5eeb0fb..23a859a 100644 --- a/src/language-helper.ts +++ b/src/language-helper.ts @@ -10,9 +10,9 @@ import { type TranslationKey as TKey } from "matrix-web-i18n"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import type Store from "electron-store"; import type EN from "./i18n/strings/en_EN.json"; import { loadJsonFile } from "./utils.js"; +import type Store from "./store.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -59,26 +59,24 @@ export function _t(text: TranslationKey, variables: Variables = {}): string { type Component = () => void; -type TypedStore = Store<{ locale?: string | string[] }>; - export class AppLocalization { private static readonly STORE_KEY = "locale"; - private readonly store: TypedStore; private readonly localizedComponents?: Set; + private readonly store: Store; - public constructor({ store, components = [] }: { store: TypedStore; components: Component[] }) { + public constructor({ components = [], store }: { components: Component[]; store: Store }) { counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN")); counterpart.setFallbackLocale(FALLBACK_LOCALE); counterpart.setSeparator("|"); + this.store = store; if (Array.isArray(components)) { this.localizedComponents = new Set(components); } - this.store = store; - if (this.store.has(AppLocalization.STORE_KEY)) { - const locales = this.store.get(AppLocalization.STORE_KEY); + if (store.has(AppLocalization.STORE_KEY)) { + const locales = store.get(AppLocalization.STORE_KEY); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.setAppLocale(locales!); } diff --git a/src/seshat.ts b/src/seshat.ts index 93f2e0b..2387349 100644 --- a/src/seshat.ts +++ b/src/seshat.ts @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import { app, ipcMain } from "electron"; import { promises as afs } from "node:fs"; import path from "node:path"; -import keytar from "keytar-forked"; import type { Seshat as SeshatType, @@ -17,6 +16,7 @@ import type { } from "matrix-seshat"; // Hak dependency type import IpcMainEvent = Electron.IpcMainEvent; import { randomArray } from "./utils.js"; +import Store from "./store.js"; let seshatSupported = false; let Seshat: typeof SeshatType; @@ -40,21 +40,24 @@ try { let eventIndex: SeshatType | null = null; const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE"; -async function getOrCreatePassphrase(key: string): Promise { - if (keytar) { - try { - const storedPassphrase = await keytar.getPassword("element.io", key); - if (storedPassphrase !== null) { - return storedPassphrase; - } else { - const newPassphrase = await randomArray(32); - await keytar.setPassword("element.io", key, newPassphrase); - return newPassphrase; - } - } catch (e) { - console.log("Error getting the event index passphrase out of the secret store", e); +async function getOrCreatePassphrase(store: Store, key: string): Promise { + try { + const storedPassphrase = await store.getSecret(key); + if (storedPassphrase !== null) { + return storedPassphrase; } + } catch (e) { + console.error("Error getting the event index passphrase out of the secret store", e); } + + try { + const newPassphrase = await randomArray(32); + await store.setSecret(key, newPassphrase); + return newPassphrase; + } catch (e) { + console.error("Error creating new event index passphrase, using default", e); + } + return seshatDefaultPassphrase; } @@ -74,7 +77,8 @@ const deleteContents = async (p: string): Promise => { }; ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise { - if (!global.mainWindow) return; + const store = Store.instance; + if (!global.mainWindow || !store) return; // We do this here to ensure we get the path after --profile has been resolved const eventStorePath = path.join(app.getPath("userData"), "EventStore"); @@ -101,7 +105,7 @@ ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise const deviceId = args[1]; const passphraseKey = `seshat|${userId}|${deviceId}`; - const passphrase = await getOrCreatePassphrase(passphraseKey); + const passphrase = await getOrCreatePassphrase(store, passphraseKey); try { await afs.mkdir(eventStorePath, { recursive: true }); diff --git a/src/settings.ts b/src/settings.ts index fbda6e9..4f042ca 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import * as tray from "./tray.js"; +import Store from "./store.js"; interface Setting { read(): Promise; @@ -27,10 +28,10 @@ export const Settings: Record = { }, "Electron.warnBeforeExit": { async read(): Promise { - return global.store.get("warnBeforeExit", true); + return Store.instance?.get("warnBeforeExit"); }, async write(value: any): Promise { - global.store.set("warnBeforeExit", value); + Store.instance?.set("warnBeforeExit", value); }, }, "Electron.alwaysShowMenuBar": { @@ -39,7 +40,7 @@ export const Settings: Record = { return !global.mainWindow!.autoHideMenuBar; }, async write(value: any): Promise { - global.store.set("autoHideMenuBar", !value); + Store.instance?.set("autoHideMenuBar", !value); global.mainWindow!.autoHideMenuBar = !value; global.mainWindow!.setMenuBarVisibility(value); }, @@ -56,15 +57,15 @@ export const Settings: Record = { } else { tray.destroy(); } - global.store.set("minimizeToTray", value); + Store.instance?.set("minimizeToTray", value); }, }, "Electron.enableHardwareAcceleration": { async read(): Promise { - return !global.store.get("disableHardwareAcceleration", false); + return !Store.instance?.get("disableHardwareAcceleration"); }, async write(value: any): Promise { - global.store.set("disableHardwareAcceleration", !value); + Store.instance?.set("disableHardwareAcceleration", !value); }, }, }; diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..497bb92 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,455 @@ +/* +Copyright 2022-2025 New Vector Ltd + +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 ElectronStore from "electron-store"; +import keytar from "keytar-forked"; +import { app, safeStorage, dialog, type SafeStorage } from "electron"; +import { clearAllUserData, relaunchApp } from "@standardnotes/electron-clear-data"; + +import { _t } from "./language-helper.js"; + +/** + * Legacy keytar service name for storing secrets. + * @deprecated + */ +const KEYTAR_SERVICE = "element.io"; +/** + * Super legacy keytar service name for reading secrets. + * @deprecated + */ +const LEGACY_KEYTAR_SERVICE = "riot.im"; + +/** + * String union type representing all the safeStorage backends. + * + The "unknown" backend shouldn't exist in practice once the app is ready + * + The "plaintext" is the temporarily-unencrypted backend for migration, data is wholly unencrypted - uses PlaintextStorageWriter + * + The "basic_text" backend is the 'plaintext' backend on Linux, data is encrypted but not using the keychain + * + The "system" backend is the encrypted backend on Windows & macOS, data is encrypted using system keychain + * + All other backends are linux-specific and are encrypted using the keychain + */ +type SafeStorageBackend = ReturnType | "system" | "plaintext"; + +/** + * Map of safeStorage backends to their command line arguments. + * kwallet6 cannot be specified via command line + * https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux + */ +const safeStorageBackendMap: Omit< + Record, + "unknown" | "kwallet6" | "system" | "plaintext" +> = { + basic_text: "basic", + gnome_libsecret: "gnome-libsecret", + kwallet: "kwallet", + kwallet5: "kwallet5", +}; + +/** + * Clear all data and relaunch the app. + */ +export async function clearDataAndRelaunch(): Promise { + Store.instance?.clear(); + clearAllUserData(); + relaunchApp(); +} + +interface StoreData { + warnBeforeExit: boolean; + minimizeToTray: boolean; + spellCheckerEnabled: boolean; + autoHideMenuBar: boolean; + locale?: string | string[]; + disableHardwareAcceleration: boolean; + safeStorage?: Record; + /** the safeStorage backend used for the safeStorage data as written */ + safeStorageBackend?: SafeStorageBackend; + /** whether to explicitly override the safeStorage backend, used for migration */ + safeStorageBackendOverride?: boolean; + /** whether to perform a migration of the safeStorage data */ + safeStorageBackendMigrate?: boolean; +} + +/** + * Fallback storage writer for secrets, mainly used for automated tests and systems without any safeStorage support. + */ +class PlaintextStorageWriter { + public constructor(protected readonly store: ElectronStore) {} + + public getKey(key: string): `safeStorage.${string}` { + return `safeStorage.${key.replaceAll(".", "-")}`; + } + + public set(key: string, secret: string): void { + this.store.set(this.getKey(key), secret); + } + + public get(key: string): string | null { + return this.store.get(this.getKey(key)); + } + + public delete(key: string): void { + this.store.delete(this.getKey(key)); + } +} + +/** + * Storage writer for secrets using safeStorage. + */ +class SafeStorageWriter extends PlaintextStorageWriter { + public set(key: string, secret: string): void { + this.store.set(this.getKey(key), safeStorage.encryptString(secret).toString("base64")); + } + + public get(key: string): string | null { + const ciphertext = this.store.get(this.getKey(key)); + if (ciphertext) { + try { + return safeStorage.decryptString(Buffer.from(ciphertext, "base64")); + } catch (e) { + console.error("Failed to decrypt secret", e); + console.error("...ciphertext:", JSON.stringify(ciphertext)); + } + } + return null; + } +} + +const enum Mode { + Encrypted = "encrypted", // default + AllowPlaintext = "allow-plaintext", + ForcePlaintext = "force-plaintext", +} + +/** + * JSON-backed store for settings which need to be accessible by the main process. + * Secrets are stored within the `safeStorage` object, encrypted with safeStorage. + * Any secrets operations are blocked on Electron app ready emit, and keytar migration if still needed. + */ +class Store extends ElectronStore { + private static internalInstance?: Store; + + public static get instance(): Store | undefined { + return Store.internalInstance; + } + + /** + * Prepare the store, does not prepare safeStorage, which needs to be done after the app is ready. + * Must be executed in the first tick of the event loop so that it can call Electron APIs before ready state. + */ + public static initialize(mode: Mode | undefined): Store { + if (Store.internalInstance) { + throw new Error("Store already initialized"); + } + + const store = new Store(mode ?? Mode.Encrypted); + Store.internalInstance = store; + + if (process.platform === "linux" && store.get("safeStorageBackendOverride")) { + const backend = store.get("safeStorageBackend")!; + if (backend in safeStorageBackendMap) { + // If the safeStorage backend which was used to write the data is one we can specify via the commandLine + // then do so to ensure we use the same backend for reading the data. + app.commandLine.appendSwitch( + "password-store", + safeStorageBackendMap[backend as keyof typeof safeStorageBackendMap], + ); + } + } + + return store; + } + + // Provides "raw" access to the underlying secrets storage, + // should be avoided in favour of the getSecret/setSecret/deleteSecret methods. + private secrets?: PlaintextStorageWriter | SafeStorageWriter; + + private constructor(private mode: Mode) { + super({ + name: "electron-config", + clearInvalidConfig: false, + schema: { + warnBeforeExit: { + type: "boolean", + default: true, + }, + minimizeToTray: { + type: "boolean", + default: true, + }, + spellCheckerEnabled: { + type: "boolean", + default: true, + }, + autoHideMenuBar: { + type: "boolean", + default: true, + }, + locale: { + anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], + }, + disableHardwareAcceleration: { + type: "boolean", + default: false, + }, + safeStorage: { + type: "object", + }, + safeStorageBackend: { + type: "string", + }, + safeStorageBackendOverride: { + type: "boolean", + }, + safeStorageBackendMigrate: { + type: "boolean", + }, + }, + }); + } + + private safeStorageReadyPromise?: Promise; + public async safeStorageReady(): Promise { + if (!this.safeStorageReadyPromise) { + this.safeStorageReadyPromise = this.prepareSafeStorage(); + } + await this.safeStorageReadyPromise; + } + + /** + * Prepare the safeStorage backend for use. + * We don't eagerly import from keytar as that would bring in data for all Element profiles and not just the current one, + * so we import lazily in getSecret. + */ + private async prepareSafeStorage(): Promise { + await app.whenReady(); + + let safeStorageBackend = this.get("safeStorageBackend"); + if (process.platform === "linux") { + // Linux safeStorage support is hellish, the support varies on the Desktop Environment used rather than the store itself. + // https://github.com/electron/electron/issues/39789 https://github.com/microsoft/vscode/issues/185212 + const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend(); + console.info( + `safeStorage backend '${selectedSafeStorageBackend}' selected, '${safeStorageBackend}' in config.`, + ); + + if (selectedSafeStorageBackend === "unknown") { + // This should never happen but good to be safe + await dialog.showMessageBox({ + title: _t("store|error|unknown_backend_override_title"), + message: _t("store|error|unknown_backend_override"), + detail: _t("store|error|unknown_backend_override_details"), + type: "error", + }); + throw new Error("safeStorage backend unknown"); + } + + if (this.get("safeStorageBackendMigrate")) { + return this.upgradeLinuxBackend2(); + } + + if (!safeStorageBackend) { + if (selectedSafeStorageBackend === "basic_text" && this.mode === Mode.Encrypted) { + const { response } = await dialog.showMessageBox({ + title: _t("store|error|unsupported_keyring_title"), + message: _t("store|error|unsupported_keyring"), + detail: _t("store|error|unsupported_keyring_detail", { + link: "https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux", + }), + type: "error", + buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_cta")], + defaultId: 0, + cancelId: 0, + }); + if (response === 0) { + throw new Error("safeStorage backend basic_text and user rejected it"); + } + this.mode = Mode.AllowPlaintext; + } + + // Store the backend used for the safeStorage data so we can detect if it changes + this.recordSafeStorageBackend(selectedSafeStorageBackend); + safeStorageBackend = selectedSafeStorageBackend; + } else if (safeStorageBackend !== selectedSafeStorageBackend) { + console.warn(`safeStorage backend changed from ${safeStorageBackend} to ${selectedSafeStorageBackend}`); + + if (safeStorageBackend === "basic_text") { + return this.upgradeLinuxBackend1(); + } else if (safeStorageBackend === "plaintext") { + this.upgradeLinuxBackend3(); + } else if (safeStorageBackend in safeStorageBackendMap) { + this.set("safeStorageBackendOverride", true); + relaunchApp(); + return; + } else { + // Warn the user that the backend has changed and tell them that we cannot migrate + const { response } = await dialog.showMessageBox({ + title: _t("store|error|backend_changed_title"), + message: _t("store|error|backend_changed"), + detail: _t("store|error|backend_changed_detail"), + type: "question", + buttons: [_t("common|no"), _t("common|yes")], + defaultId: 0, + cancelId: 0, + }); + if (response === 0) { + throw new Error("safeStorage backend changed and cannot migrate"); + } + await clearDataAndRelaunch(); + } + } + + // We do not check allowPlaintextStorage here as it was already checked above if the storage is new + // and if the storage is existing then we should continue to honour the backend used to write the data + if (safeStorageBackend === "basic_text" && selectedSafeStorageBackend === safeStorageBackend) { + safeStorage.setUsePlainTextEncryption(true); + } + } else if (!safeStorageBackend) { + safeStorageBackend = this.mode === Mode.Encrypted ? "system" : "plaintext"; + this.recordSafeStorageBackend(safeStorageBackend); + } + + if (this.mode !== Mode.ForcePlaintext && safeStorage.isEncryptionAvailable()) { + this.secrets = new SafeStorageWriter(this); + } else if (this.mode !== Mode.Encrypted) { + this.secrets = new PlaintextStorageWriter(this); + } else { + throw new Error(`safeStorage is not available`); + } + + console.info(`Using storage mode '${this.mode}' with backend '${safeStorageBackend}'`); + } + + private recordSafeStorageBackend(backend: SafeStorageBackend): void { + this.set("safeStorageBackend", backend); + } + + /** + * Linux support for upgrading the backend from basic_text to one of the encrypted backends, + * this is quite a tricky process as the backend is not known until the app is ready & cannot be changed once it is. + * First we restart the app in basic_text backend mode, then decrypt the data & restart back in default backend mode, + * and re-encrypt the data. + */ + private upgradeLinuxBackend1(): void { + console.info(`Starting safeStorage migration to ${safeStorage.getSelectedStorageBackend()}`); + this.set("safeStorageBackendMigrate", true); + relaunchApp(); + } + private upgradeLinuxBackend2(): void { + if (!this.secrets) throw new Error("safeStorage not ready"); + console.info("Performing safeStorage migration"); + const data = this.get("safeStorage"); + if (data) { + for (const key in data) { + this.set(this.secrets.getKey(key), this.secrets!.get(key)); + } + this.recordSafeStorageBackend("plaintext"); + } + this.set("safeStorageBackendMigrate", false); + relaunchApp(); + } + private upgradeLinuxBackend3(): void { + if (!this.secrets) throw new Error("safeStorage not ready"); + const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend(); + console.info(`Finishing safeStorage migration to ${selectedSafeStorageBackend}`); + const data = this.get("safeStorage"); + if (data) { + for (const key in data) { + this.secrets.set(key, data[key]); + } + } + this.recordSafeStorageBackend(selectedSafeStorageBackend); + } + + /** + * Get the stored secret for the key. + * Lazily migrates keys from keytar if they are not yet in the store. + * + * @param key The string key name. + * + * @returns A promise for the secret string. + */ + public async getSecret(key: string): Promise { + await this.safeStorageReady(); + let secret = this.secrets!.get(key); + if (secret) return secret; + + try { + secret = await this.getSecretKeytar(key); + } catch (e) { + console.warn(`Failed to read data from keytar with key='${key}'`, e); + } + if (secret) { + console.debug("Migrating secret from keytar", key); + this.secrets!.set(key, secret); + } + + return secret; + } + + /** + * Add the secret for the key to the keychain. + * We write to both safeStorage & keytar to support downgrading the application. + * + * @param key The string key name. + * @param secret The string password. + * + * @returns A promise for the set password completion. + */ + public async setSecret(key: string, secret: string): Promise { + await this.safeStorageReady(); + this.secrets!.set(key, secret); + try { + await keytar.setPassword(KEYTAR_SERVICE, key, secret); + } catch (e) { + console.warn(`Failed to write safeStorage backwards-compatibility key='${key}' data to keytar`, e); + } + } + + /** + * Delete the stored password for the key. + * Removes from safeStorage, keytar & keytar legacy. + * + * @param key The string key name. + */ + public async deleteSecret(key: string): Promise { + await this.safeStorageReady(); + this.secrets!.delete(key); + try { + await this.deleteSecretKeytar(key); + } catch (e) { + console.warn(`Failed to delete secret with key='${key}' from keytar`, e); + } + } + + /** + * @deprecated will be removed in the near future + */ + private async getSecretKeytar(key: string): Promise { + return ( + (await keytar.getPassword(KEYTAR_SERVICE, key)) ?? (await keytar.getPassword(LEGACY_KEYTAR_SERVICE, key)) + ); + } + + /** + * @deprecated will be removed in the near future + */ + private async deleteSecretKeytar(key: string): Promise { + await keytar.deletePassword(LEGACY_KEYTAR_SERVICE, key); + await keytar.deletePassword(KEYTAR_SERVICE, key); + } +} + +export default Store;