diff --git a/src/electron-main.ts b/src/electron-main.ts index 488ec723..db6cd0af 100644 --- a/src/electron-main.ts +++ b/src/electron-main.ts @@ -364,6 +364,8 @@ 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"); +store.prepare(); // must be called before any async actions + // Disable hardware acceleration if the setting has been set. if (store.get("disableHardwareAcceleration") === true) { console.log("Disabling hardware acceleration."); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2bdb0bc9..22538512 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -19,6 +19,8 @@ "zoom_out": "Zoom Out" }, "common": { + "yes": "Yes", + "no": "No", "about": "About", "brand_help": "%(brand)s Help", "help": "Help", @@ -59,5 +61,12 @@ "bring_all_to_front": "Bring All to Front", "label": "Window", "zoom": "Zoom" + }, + "store": { + "error": { + "title": "Failed to load configuration", + "unsupported_backend_override": "TODO", + "unknown_backend_override": "TODO" + } } } diff --git a/src/store.ts b/src/store.ts index 5bfb6eab..c390a46f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,7 +16,9 @@ limitations under the License. import ElectronStore from "electron-store"; import keytar from "keytar-forked"; -import { app, safeStorage } from "electron"; +import { app, safeStorage, dialog, type SafeStorage } from "electron"; + +import { _t } from "./language-helper.js"; /** * Legacy keytar service names for storing secrets. @@ -24,6 +26,20 @@ import { app, safeStorage } from "electron"; const KEYTAR_SERVICE = "element.io"; const LEGACY_KEYTAR_SERVICE = "riot.im"; +type SafeStorageBackend = ReturnType; + +/** + * 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, "unknown" | "kwallet6"> = { + basic_text: "basic", + gnome_libsecret: "gnome-libsecret", + kwallet: "kwallet", + kwallet5: "kwallet5", +}; + /** * JSON-backed store for settings which need to be accessible by the main process. * Secrets are stored within the `safeStorage` object, encrypted with safeStorage. @@ -37,6 +53,10 @@ class Store extends ElectronStore<{ locale?: string | string[]; disableHardwareAcceleration: boolean; safeStorage?: Record; + // Only known for Linux - the safeStorage backend used for the safeStorage data as written + safeStorageBackend?: SafeStorageBackend; + // Only valid for Linux - whether to override the safeStorage backend via commandLine + safeStorageBackendOverride?: boolean; }> { public constructor() { super({ @@ -69,37 +89,147 @@ class Store extends ElectronStore<{ safeStorage: { type: "object", }, + safeStorageBackend: { + type: "string", + }, + safeStorageBackendOverride: { + type: "boolean", + }, }, }); } - private whenSafeStorageReadyPromise?: Promise; - public async safeStorageReady(): Promise { - if (!this.whenSafeStorageReadyPromise) { - this.whenSafeStorageReadyPromise = Promise.allSettled([app.whenReady().then(() => this.migrateSecrets())]); + /** + * 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 prepare(): void { + if (process.platform === "linux") { + if (this.get("safeStorageBackendOverride")) { + const backend = this.get("safeStorageBackend")!; + if (backend in safeStorageBackendMap) { + app.commandLine.appendSwitch( + "password-store", + safeStorageBackendMap[backend as keyof typeof safeStorageBackendMap], + ); + } else { + // This case should never happen, but could due to a downgrade or a modified store. + dialog.showErrorBox(_t("store|error|title"), _t("store|error|unsupported_backend_override")); + throw new Error("safeStorage backend override is not supported"); + } + } } - await this.whenSafeStorageReadyPromise; + } + + private safeStorageReadyPromise?: Promise; + public async safeStorageReady(): Promise { + if (!this.safeStorageReadyPromise) { + this.safeStorageReadyPromise = this.prepareSafeStorage(); + } + await this.safeStorageReadyPromise; } private getSecretStorageKey = (key: string) => `safeStorage.${key}` as const; + private async prepareSafeStorage(): Promise { + await app.whenReady(); + + 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 + let safeStorageBackend = this.get("safeStorageBackend"); + const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend(); + + if (selectedSafeStorageBackend === "unknown") { + // This should never happen but good to be safe + dialog.showErrorBox(_t("store|error|title"), _t("store|error|unknown_backend_override")); + throw new Error("safeStorage backend unknown"); + } + + if (!safeStorageBackend) { + if (selectedSafeStorageBackend === "basic_text") { + // Ask the user if they want to use plain text encryption + // TODO should we only do this if they have existing data + const { response } = await dialog.showMessageBox({ + // TODO + title: "Error 1", + message: "Message", + // detail: _t(""), + type: "question", + buttons: [_t("common|no"), _t("common|yes")], + defaultId: 0, + cancelId: 0, + }); + if (response === 0) { + throw new Error("safeStorage backend basic_text and user rejected it"); + } + } + + // Store the backend used for the safeStorage data so we can detect if it changes + this.set("safeStorageBackend", selectedSafeStorageBackend); + safeStorageBackend = selectedSafeStorageBackend; + } else if (safeStorageBackend !== selectedSafeStorageBackend) { + console.warn(`safeStorage backend changed from ${safeStorageBackend} to ${selectedSafeStorageBackend}`); + + if (safeStorageBackend === "basic_text") { + console.info(`Migrating safeStorage from basic_text to ${selectedSafeStorageBackend}`); + const data = this.get("safeStorage"); + if (data) { + for (const key in data) { + const plaintext = data[key]; + await this.setSecret(key, plaintext); + } + } + } else if (safeStorageBackend in safeStorageBackendMap) { + // Warn the user that the backend has changed and ask if they wish to use the old one + const { response } = await dialog.showMessageBox({ + // TODO + title: "Error 2", + message: "Message", + // detail: _t(""), + type: "question", + buttons: [_t("common|no"), _t("common|yes")], + defaultId: 0, + cancelId: 0, + }); + if (response === 0) { + throw new Error("safeStorage backend changed and user rejected mitigation"); + } + this.set("safeStorageBackendOverride", true); + app.relaunch(); + } else { + // Warn the user that the backend has changed and tell them that we cannot migrate + // dialog.showErrorBox(_t(""), _t("")); TODO + throw new Error("safeStorage backend changed and cannot migrate"); + } + } + + if (safeStorageBackend === "basic_text" && selectedSafeStorageBackend === safeStorageBackend) { + // TODO verify if this even works, the docstring makes it sound ephemeral! + safeStorage.setUsePlainTextEncryption(true); + } + } + + if (!safeStorage.isEncryptionAvailable()) { + console.error("Store migration: safeStorage is not available"); + throw new Error(`safeStorage is not available`); + // TODO fatal error + } + + await this.migrateSecrets(); + } + /** * Migrates keytar data to safeStorage, * deletes data from legacy keytar but keeps it in the new keytar for downgrade compatibility. - * @throws if safeStorage is not available. + * @throws if safeStorage is not available. TODO */ private async migrateSecrets(): Promise { - if (this.has("safeStorage")) return; + if (this.has("safeStorage")) return; // already migrated console.info("Store migration: started"); - if ( - !safeStorage.isEncryptionAvailable() && - !(process.platform === "linux" && safeStorage.getSelectedStorageBackend() === "basic_text") - ) { - console.error( - "Store migration: safeStorage is not available with backend", - safeStorage.getSelectedStorageBackend(), - ); - throw new Error("safeStorage is not available"); + + if (process.platform === "linux" && safeStorage.getSelectedStorageBackend() === "basic_text") { + console.warn("Store migration: safeStorage is using basic text encryption"); } try {