Migrate from keytar to safeStorage (#2227)

This commit is contained in:
Michael Telatynski
2025-04-29 11:40:06 +01:00
committed by GitHub
parent 570a8bdefe
commit c72d23f995
12 changed files with 623 additions and 115 deletions

View File

@@ -67,7 +67,7 @@ jobs:
- name: Run tests - name: Run tests
uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
timeout-minutes: 5 timeout-minutes: 20
with: with:
run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }} run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }}
env: env:

View File

@@ -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. 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"; import { test, expect } from "../../element-desktop-test.js";
@@ -17,6 +17,7 @@ declare global {
supportsEventIndexing(): Promise<boolean>; supportsEventIndexing(): Promise<boolean>;
} }
| undefined; | undefined;
getPickleKey(userId: string, deviceId: string): Promise<string | null>;
createPickleKey(userId: string, deviceId: string): Promise<string | null>; createPickleKey(userId: string, deviceId: string): Promise<string | null>;
} }
@@ -48,14 +49,46 @@ test.describe("App launch", () => {
).resolves.toBeTruthy(); ).resolves.toBeTruthy();
}); });
test("should launch and render the welcome view successfully and support keytar", async ({ page }) => { test.describe("safeStorage", () => {
test.skip(platform() === "linux", "This test does not yet support Linux"); const userId = "@user:server";
const deviceId = "ABCDEF";
await expect( test("should be supported", async ({ page }) => {
page.evaluate<string | null>(async () => { await expect(
return await window.mxPlatformPeg.get().createPickleKey("@user:server", "ABCDEF"); page.evaluate(
}), ([userId, deviceId]) => window.mxPlatformPeg.get().createPickleKey(userId, deviceId),
).resolves.not.toBeNull(); [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", () => { test.describe("--no-update", () => {

View File

@@ -62,12 +62,21 @@ export const test = base.extend<Fixtures>({
// eslint-disable-next-line no-empty-pattern // eslint-disable-next-line no-empty-pattern
tmpDir: async ({}, use) => { tmpDir: async ({}, use) => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-"));
console.log("Using temp profile directory: ", tmpDir);
await use(tmpDir); await use(tmpDir);
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}, },
app: async ({ tmpDir, extraEnv, extraArgs, stdout, stderr }, use) => { 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"]; const executablePath = process.env["ELEMENT_DESKTOP_EXECUTABLE"];
if (!executablePath) { if (!executablePath) {
@@ -75,13 +84,15 @@ export const test = base.extend<Fixtures>({
args.unshift(path.join(__dirname, "..", "lib", "electron-main.js")); args.unshift(path.join(__dirname, "..", "lib", "electron-main.js"));
} }
console.log(`Launching '${executablePath}' with args ${args.join(" ")}`);
const app = await electron.launch({ const app = await electron.launch({
env: { env: {
...process.env, ...process.env,
...extraEnv, ...extraEnv,
}, },
executablePath, executablePath,
args: [...args, ...extraArgs], args,
}); });
app.process().stdout.pipe(stdout).pipe(process.stdout); app.process().stdout.pipe(stdout).pipe(process.stdout);

View File

@@ -8,7 +8,7 @@
"module": "node16", "module": "node16",
"sourceMap": false, "sourceMap": false,
"strict": true, "strict": true,
"lib": ["es2020"] "lib": ["es2021"]
}, },
"include": ["../src/@types", "./**/*.ts"] "include": ["../src/@types", "./**/*.ts"]
} }

View File

@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
import { type BrowserWindow } from "electron"; import { type BrowserWindow } from "electron";
import type Store from "electron-store";
import type AutoLaunch from "auto-launch"; import type AutoLaunch from "auto-launch";
import { type AppLocalization } from "../language-helper.js"; import { type AppLocalization } from "../language-helper.js";
@@ -24,13 +23,5 @@ declare global {
icon_path: string; icon_path: string;
brand: string; brand: string;
}; };
var store: Store<{
warnBeforeExit?: boolean;
minimizeToTray?: boolean;
spellCheckerEnabled?: boolean;
autoHideMenuBar?: boolean;
locale?: string | string[];
disableHardwareAcceleration?: boolean;
}>;
} }
/* eslint-enable no-var */ /* eslint-enable no-var */

View File

@@ -16,7 +16,6 @@ import * as Sentry from "@sentry/electron/main";
import AutoLaunch from "auto-launch"; import AutoLaunch from "auto-launch";
import path, { dirname } from "node:path"; import path, { dirname } from "node:path";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import Store from "electron-store";
import fs, { promises as afs } from "node:fs"; import fs, { promises as afs } from "node:fs";
import { URL, fileURLToPath } from "node:url"; import { URL, fileURLToPath } from "node:url";
import minimist from "minimist"; import minimist from "minimist";
@@ -25,6 +24,7 @@ import "./ipc.js";
import "./seshat.js"; import "./seshat.js";
import "./settings.js"; import "./settings.js";
import * as tray from "./tray.js"; import * as tray from "./tray.js";
import Store from "./store.js";
import { buildMenuTemplate } from "./vectormenu.js"; import { buildMenuTemplate } from "./vectormenu.js";
import webContentsHandler from "./webcontents-handler.js"; import webContentsHandler from "./webcontents-handler.js";
import * as updater from "./updater.js"; import * as updater from "./updater.js";
@@ -273,8 +273,6 @@ async function moveAutoLauncher(): Promise<void> {
} }
} }
global.store = new Store({ name: "electron-config" });
global.appQuitting = false; global.appQuitting = false;
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ 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", 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(); void configureSentry();
// handle uncaught errors otherwise it displays // 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 // 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"); 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. // 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."); console.log("Disabling hardware acceleration.");
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
} }
app.on("ready", async () => { app.on("ready", async () => {
console.debug("Reached Electron ready state");
let asarPath: string; let asarPath: string;
try { try {
@@ -462,12 +438,27 @@ app.on("ready", async () => {
console.log("No update_base_url is defined: auto update is disabled"); 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 // Load the previous window state with fallback to defaults
const mainWindowState = windowStateKeeper({ const mainWindowState = windowStateKeeper({
defaultWidth: 1024, defaultWidth: 1024,
defaultHeight: 768, defaultHeight: 768,
}); });
console.debug("Opening main window");
const preloadScript = path.normalize(`${__dirname}/preload.cjs`); const preloadScript = path.normalize(`${__dirname}/preload.cjs`);
global.mainWindow = new BrowserWindow({ global.mainWindow = new BrowserWindow({
// https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do // 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, icon: global.trayConfig.icon_path,
show: false, show: false,
autoHideMenuBar: global.store.get("autoHideMenuBar", true), autoHideMenuBar: store.get("autoHideMenuBar"),
x: mainWindowState.x, x: mainWindowState.x,
y: mainWindowState.y, y: mainWindowState.y,
@@ -500,10 +491,10 @@ app.on("ready", async () => {
// Handle spellchecker // Handle spellchecker
// For some reason spellCheckerEnabled isn't persisted, so we have to use the store here // 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 // 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", () => { global.mainWindow.once("ready-to-show", () => {
if (!global.mainWindow) return; 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.on("closed", () => {
global.mainWindow = null; global.mainWindow = null;
@@ -555,11 +570,6 @@ app.on("ready", async () => {
webContentsHandler(global.mainWindow.webContents); webContentsHandler(global.mainWindow.webContents);
global.appLocalization = new AppLocalization({
store: global.store,
components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())],
});
session.defaultSession.setDisplayMediaRequestHandler((_, callback) => { session.defaultSession.setDisplayMediaRequestHandler((_, callback) => {
global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker"); global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker");
setDisplayMediaCallback(callback); setDisplayMediaCallback(callback);

View File

@@ -22,7 +22,9 @@
"about": "About", "about": "About",
"brand_help": "%(brand)s Help", "brand_help": "%(brand)s Help",
"help": "Help", "help": "Help",
"preferences": "Preferences" "no": "No",
"preferences": "Preferences",
"yes": "Yes"
}, },
"confirm_quit": "Are you sure you want to quit?", "confirm_quit": "Are you sure you want to quit?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,20 @@
"save_image_as_error_description": "The image failed to save", "save_image_as_error_description": "The image failed to save",
"save_image_as_error_title": "Failed to save image" "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": { "view_menu": {
"actual_size": "Actual Size", "actual_size": "Actual Size",
"toggle_developer_tools": "Toggle Developer Tools", "toggle_developer_tools": "Toggle Developer Tools",

View File

@@ -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 { 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 IpcMainEvent = Electron.IpcMainEvent;
import { recordSSOSession } from "./protocol.js"; import { recordSSOSession } from "./protocol.js";
import { randomArray } from "./utils.js"; import { randomArray } from "./utils.js";
import { Settings } from "./settings.js"; import { Settings } from "./settings.js";
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
import Store, { clearDataAndRelaunch } from "./store.js";
ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void { ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void {
if (process.platform !== "win32") { if (process.platform !== "win32") {
@@ -61,7 +60,8 @@ ipcMain.on("app_onAction", function (_ev: IpcMainEvent, payload) {
}); });
ipcMain.on("ipcCall", async 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 || []; const args = payload.args || [];
let ret: any; let ret: any;
@@ -113,11 +113,11 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
if (typeof args[0] !== "boolean") return; if (typeof args[0] !== "boolean") return;
global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]); global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]);
global.store.set("spellCheckerEnabled", args[0]); store.set("spellCheckerEnabled", args[0]);
break; break;
case "getSpellCheckEnabled": case "getSpellCheckEnabled":
ret = global.store.get("spellCheckerEnabled", true); ret = store.get("spellCheckerEnabled");
break; break;
case "setSpellCheckLanguages": case "setSpellCheckLanguages":
@@ -141,12 +141,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
case "getPickleKey": case "getPickleKey":
try { try {
ret = await keytar.getPassword("element.io", `${args[0]}|${args[1]}`); ret = await store.getSecret(`${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]}`);
}
} catch { } catch {
// if an error is thrown (e.g. keytar can't connect to the keychain), // 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 // 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": case "createPickleKey":
try { try {
const pickleKey = await randomArray(32); const pickleKey = await randomArray(32);
// We purposefully throw if keytar is not available so the caller can handle it await store.setSecret(`${args[0]}|${args[1]}`, pickleKey);
// rather than sending them a pickle key we did not store on their behalf.
await keytar!.setPassword("element.io", `${args[0]}|${args[1]}`, pickleKey);
ret = pickleKey; ret = pickleKey;
} catch (e) { } catch (e) {
console.error("Failed to create pickle key", e); console.error("Failed to create pickle key", e);
@@ -169,11 +162,10 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
case "destroyPickleKey": case "destroyPickleKey":
try { try {
await keytar.deletePassword("element.io", `${args[0]}|${args[1]}`); await store.deleteSecret(`${args[0]}|${args[1]}`);
// migrate from riot.im (remove once we think there will no longer be } catch (e) {
// logins from the time of riot.im) console.error("Failed to destroy pickle key", e);
await keytar.deletePassword("riot.im", `${args[0]}|${args[1]}`); }
} catch {}
break; break;
case "getDesktopCapturerSources": case "getDesktopCapturerSources":
ret = (await desktopCapturer.getSources(args[0])).map((source) => ({ ret = (await desktopCapturer.getSources(args[0])).map((source) => ({
@@ -189,10 +181,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
break; break;
case "clearStorage": case "clearStorage":
global.store.clear(); await clearDataAndRelaunch();
global.mainWindow.webContents.session.flushStorageData();
await global.mainWindow.webContents.session.clearStorageData();
relaunchApp();
return; // the app is about to stop, we don't need to reply to the IPC return; // the app is about to stop, we don't need to reply to the IPC
case "breadcrumbs": { case "breadcrumbs": {

View File

@@ -10,9 +10,9 @@ import { type TranslationKey as TKey } from "matrix-web-i18n";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type Store from "electron-store";
import type EN from "./i18n/strings/en_EN.json"; import type EN from "./i18n/strings/en_EN.json";
import { loadJsonFile } from "./utils.js"; import { loadJsonFile } from "./utils.js";
import type Store from "./store.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -59,26 +59,24 @@ export function _t(text: TranslationKey, variables: Variables = {}): string {
type Component = () => void; type Component = () => void;
type TypedStore = Store<{ locale?: string | string[] }>;
export class AppLocalization { export class AppLocalization {
private static readonly STORE_KEY = "locale"; private static readonly STORE_KEY = "locale";
private readonly store: TypedStore;
private readonly localizedComponents?: Set<Component>; private readonly localizedComponents?: Set<Component>;
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.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN"));
counterpart.setFallbackLocale(FALLBACK_LOCALE); counterpart.setFallbackLocale(FALLBACK_LOCALE);
counterpart.setSeparator("|"); counterpart.setSeparator("|");
this.store = store;
if (Array.isArray(components)) { if (Array.isArray(components)) {
this.localizedComponents = new Set(components); this.localizedComponents = new Set(components);
} }
this.store = store; if (store.has(AppLocalization.STORE_KEY)) {
if (this.store.has(AppLocalization.STORE_KEY)) { const locales = store.get(AppLocalization.STORE_KEY);
const locales = this.store.get(AppLocalization.STORE_KEY);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.setAppLocale(locales!); this.setAppLocale(locales!);
} }

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import { promises as afs } from "node:fs"; import { promises as afs } from "node:fs";
import path from "node:path"; import path from "node:path";
import keytar from "keytar-forked";
import type { import type {
Seshat as SeshatType, Seshat as SeshatType,
@@ -17,6 +16,7 @@ import type {
} from "matrix-seshat"; // Hak dependency type } from "matrix-seshat"; // Hak dependency type
import IpcMainEvent = Electron.IpcMainEvent; import IpcMainEvent = Electron.IpcMainEvent;
import { randomArray } from "./utils.js"; import { randomArray } from "./utils.js";
import Store from "./store.js";
let seshatSupported = false; let seshatSupported = false;
let Seshat: typeof SeshatType; let Seshat: typeof SeshatType;
@@ -40,21 +40,24 @@ try {
let eventIndex: SeshatType | null = null; let eventIndex: SeshatType | null = null;
const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE"; const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE";
async function getOrCreatePassphrase(key: string): Promise<string> { async function getOrCreatePassphrase(store: Store, key: string): Promise<string> {
if (keytar) { try {
try { const storedPassphrase = await store.getSecret(key);
const storedPassphrase = await keytar.getPassword("element.io", key); if (storedPassphrase !== null) {
if (storedPassphrase !== null) { return storedPassphrase;
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);
} }
} 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; return seshatDefaultPassphrase;
} }
@@ -74,7 +77,8 @@ const deleteContents = async (p: string): Promise<void> => {
}; };
ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> { ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> {
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 // We do this here to ensure we get the path after --profile has been resolved
const eventStorePath = path.join(app.getPath("userData"), "EventStore"); const eventStorePath = path.join(app.getPath("userData"), "EventStore");
@@ -101,7 +105,7 @@ ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void>
const deviceId = args[1]; const deviceId = args[1];
const passphraseKey = `seshat|${userId}|${deviceId}`; const passphraseKey = `seshat|${userId}|${deviceId}`;
const passphrase = await getOrCreatePassphrase(passphraseKey); const passphrase = await getOrCreatePassphrase(store, passphraseKey);
try { try {
await afs.mkdir(eventStorePath, { recursive: true }); await afs.mkdir(eventStorePath, { recursive: true });

View File

@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import * as tray from "./tray.js"; import * as tray from "./tray.js";
import Store from "./store.js";
interface Setting { interface Setting {
read(): Promise<any>; read(): Promise<any>;
@@ -27,10 +28,10 @@ export const Settings: Record<string, Setting> = {
}, },
"Electron.warnBeforeExit": { "Electron.warnBeforeExit": {
async read(): Promise<any> { async read(): Promise<any> {
return global.store.get("warnBeforeExit", true); return Store.instance?.get("warnBeforeExit");
}, },
async write(value: any): Promise<void> { async write(value: any): Promise<void> {
global.store.set("warnBeforeExit", value); Store.instance?.set("warnBeforeExit", value);
}, },
}, },
"Electron.alwaysShowMenuBar": { "Electron.alwaysShowMenuBar": {
@@ -39,7 +40,7 @@ export const Settings: Record<string, Setting> = {
return !global.mainWindow!.autoHideMenuBar; return !global.mainWindow!.autoHideMenuBar;
}, },
async write(value: any): Promise<void> { async write(value: any): Promise<void> {
global.store.set("autoHideMenuBar", !value); Store.instance?.set("autoHideMenuBar", !value);
global.mainWindow!.autoHideMenuBar = !value; global.mainWindow!.autoHideMenuBar = !value;
global.mainWindow!.setMenuBarVisibility(value); global.mainWindow!.setMenuBarVisibility(value);
}, },
@@ -56,15 +57,15 @@ export const Settings: Record<string, Setting> = {
} else { } else {
tray.destroy(); tray.destroy();
} }
global.store.set("minimizeToTray", value); Store.instance?.set("minimizeToTray", value);
}, },
}, },
"Electron.enableHardwareAcceleration": { "Electron.enableHardwareAcceleration": {
async read(): Promise<any> { async read(): Promise<any> {
return !global.store.get("disableHardwareAcceleration", false); return !Store.instance?.get("disableHardwareAcceleration");
}, },
async write(value: any): Promise<void> { async write(value: any): Promise<void> {
global.store.set("disableHardwareAcceleration", !value); Store.instance?.set("disableHardwareAcceleration", !value);
}, },
}, },
}; };

455
src/store.ts Normal file
View File

@@ -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<SafeStorage["getSelectedStorageBackend"]> | "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<SafeStorageBackend, string>,
"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<void> {
Store.instance?.clear();
clearAllUserData();
relaunchApp();
}
interface StoreData {
warnBeforeExit: boolean;
minimizeToTray: boolean;
spellCheckerEnabled: boolean;
autoHideMenuBar: boolean;
locale?: string | string[];
disableHardwareAcceleration: boolean;
safeStorage?: Record<string, string>;
/** 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<StoreData>) {}
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<string, string | undefined>(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<StoreData> {
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<unknown>;
public async safeStorageReady(): Promise<void> {
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<void> {
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<string | null> {
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<void> {
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<void> {
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<string | null> {
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<void> {
await keytar.deletePassword(LEGACY_KEYTAR_SERVICE, key);
await keytar.deletePassword(KEYTAR_SERVICE, key);
}
}
export default Store;