diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 20dd96f..d378b51 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -19,6 +19,7 @@ import Store from "electron-store"; import AutoLaunch from "auto-launch"; import { AppLocalization } from "../language-helper"; +import { StoreData } from "../electron-main"; // global type extensions need to use var for whatever reason /* eslint-disable no-var */ @@ -33,13 +34,6 @@ declare global { icon_path: string; brand: string; }; - var store: Store<{ - warnBeforeExit?: boolean; - minimizeToTray?: boolean; - spellCheckerEnabled?: boolean; - autoHideMenuBar?: boolean; - locale?: string | string[]; - disableHardwareAcceleration?: boolean; - }>; + var store: Store; } /* eslint-enable no-var */ diff --git a/src/electron-main.ts b/src/electron-main.ts index 4b7a592..0e3910f 100644 --- a/src/electron-main.ts +++ b/src/electron-main.ts @@ -30,10 +30,10 @@ import { URL } from "url"; import minimist from "minimist"; import "./ipc"; -import "./keytar"; import "./seshat"; import "./settings"; import * as tray from "./tray"; +import { migrate as migrateSafeStorage } from "./safe-storage"; import { buildMenuTemplate } from "./vectormenu"; import webContentsHandler from "./webcontents-handler"; import * as updater from "./updater"; @@ -252,7 +252,53 @@ async function moveAutoLauncher(): Promise { } } -global.store = new Store({ name: "electron-config" }); +export interface StoreData { + warnBeforeExit: boolean; + minimizeToTray: boolean; + spellCheckerEnabled: boolean; + autoHideMenuBar: boolean; + locale?: string | string[]; + disableHardwareAcceleration: boolean; + migratedToSafeStorage: boolean; + safeStorage: Record; +} + +global.store = new Store({ + name: "electron-config", + 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, + }, + migratedToSafeStorage: { + type: "boolean", + default: false, + }, + safeStorage: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, +}) as Store; global.appQuitting = false; @@ -345,12 +391,14 @@ app.enableSandbox(); app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService"); // Disable hardware acceleration if the setting has been set. -if (global.store.get("disableHardwareAcceleration", false) === true) { +if (global.store.get("disableHardwareAcceleration") === true) { console.log("Disabling hardware acceleration."); app.disableHardwareAcceleration(); } app.on("ready", async () => { + await migrateSafeStorage(); + let asarPath: string; try { @@ -456,7 +504,7 @@ app.on("ready", async () => { icon: global.trayConfig.icon_path, show: false, - autoHideMenuBar: global.store.get("autoHideMenuBar", true), + autoHideMenuBar: global.store.get("autoHideMenuBar"), x: mainWindowState.x, y: mainWindowState.y, @@ -477,7 +525,7 @@ app.on("ready", async () => { global.mainWindow.webContents.session.setSpellCheckerEnabled(global.store.get("spellCheckerEnabled", true)); // Create trayIcon icon - if (global.store.get("minimizeToTray", true)) tray.create(global.trayConfig); + if (global.store.get("minimizeToTray")) tray.create(global.trayConfig); global.mainWindow.once("ready-to-show", () => { if (!global.mainWindow) return; diff --git a/src/ipc.ts b/src/ipc.ts index b84fb95..f32527a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -21,7 +21,7 @@ import IpcMainEvent = Electron.IpcMainEvent; import { recordSSOSession } from "./protocol"; import { randomArray } from "./utils"; import { Settings } from "./settings"; -import { keytar } from "./keytar"; +import { deletePassword, getPassword, setPassword } from "./safe-storage"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback"; ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void { @@ -125,7 +125,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { break; case "getSpellCheckEnabled": - ret = global.store.get("spellCheckerEnabled", true); + ret = global.store.get("spellCheckerEnabled"); break; case "setSpellCheckLanguages": @@ -149,12 +149,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 getPassword(`${args[0]}|${args[1]}`); } catch (e) { // 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 @@ -165,7 +160,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "createPickleKey": try { const pickleKey = await randomArray(32); - await keytar?.setPassword("element.io", `${args[0]}|${args[1]}`, pickleKey); + await setPassword(`${args[0]}|${args[1]}`, pickleKey); ret = pickleKey; } catch (e) { ret = null; @@ -174,10 +169,7 @@ 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]}`); + await deletePassword(`${args[0]}|${args[1]}`); } catch (e) {} break; case "getDesktopCapturerSources": diff --git a/src/keytar.ts b/src/keytar.ts deleted file mode 100644 index 7138032..0000000 --- a/src/keytar.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2022 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 type * as Keytar from "keytar"; // Hak dependency type - -let keytar: typeof Keytar | undefined; -try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - keytar = require("keytar"); -} catch (e) { - if ((e).code === "MODULE_NOT_FOUND") { - console.log("Keytar isn't installed; secure key storage is disabled."); - } else { - console.warn("Keytar unexpected error:", e); - } -} - -export { keytar }; diff --git a/src/language-helper.ts b/src/language-helper.ts index 8db65a9..28499d6 100644 --- a/src/language-helper.ts +++ b/src/language-helper.ts @@ -16,8 +16,6 @@ limitations under the License. import counterpart from "counterpart"; -import type Store from "electron-store"; - const FALLBACK_LOCALE = "en"; export function _td(text: string): string { @@ -63,7 +61,7 @@ export function _t(text: string, variables: IVariables = {}): string { type Component = () => void; -type TypedStore = Store<{ locale?: string | string[] }>; +type TypedStore = (typeof global)["store"]; export class AppLocalization { private static readonly STORE_KEY = "locale"; diff --git a/src/safe-storage.ts b/src/safe-storage.ts new file mode 100644 index 0000000..48d19de --- /dev/null +++ b/src/safe-storage.ts @@ -0,0 +1,106 @@ +/* +Copyright 2022 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 { safeStorage } from "electron"; + +import type * as Keytar from "keytar"; + +const KEYTAR_SERVICE = "element.io"; +const LEGACY_KEYTAR_SERVICE = "riot.im"; + +let keytar: typeof Keytar | undefined; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + keytar = require("keytar"); +} catch (e) { + if ((e).code === "MODULE_NOT_FOUND") { + console.log("Keytar isn't installed; secure key storage is disabled."); + } else { + console.warn("Keytar unexpected error:", e); + } +} + +export async function migrate(): Promise { + if (global.store.get("migratedToSafeStorage")) return; // already done + + if (keytar) { + const credentials = [ + ...(await keytar.findCredentials(LEGACY_KEYTAR_SERVICE)), + ...(await keytar.findCredentials(KEYTAR_SERVICE)), + ]; + credentials.forEach((cred) => { + deletePassword(cred.account); + setPassword(cred.account, cred.password); + }); + } + + global.store.set("migratedToSafeStorage", true); +} + +/** + * Get the stored password for the key. + * + * @param key The string key name. + * + * @returns A promise for the password string. + */ +export async function getPassword(key: string): Promise { + if (safeStorage.isEncryptionAvailable()) { + const encryptedValue = global.store.get(`safeStorage.${key}`); + if (typeof encryptedValue === "string") { + return safeStorage.decryptString(Buffer.from(encryptedValue)); + } + } + if (keytar) { + return ( + (await keytar.getPassword(KEYTAR_SERVICE, key)) ?? (await keytar.getPassword(LEGACY_KEYTAR_SERVICE, key)) + ); + } + return null; +} + +/** + * Add the password for the key to the keychain. + * + * @param key The string key name. + * @param password The string password. + * + * @returns A promise for the set password completion. + */ +export async function setPassword(key: string, password: string): Promise { + if (safeStorage.isEncryptionAvailable()) { + const encryptedValue = safeStorage.encryptString(password); + global.store.set(`safeStorage.${key}`, encryptedValue.toString()); + } + await keytar?.setPassword(KEYTAR_SERVICE, key, password); +} + +/** + * Delete the stored password for the key. + * + * @param key The string key name. + * + * @returns A promise for the deletion status. True on success. + */ +export async function deletePassword(key: string): Promise { + if (safeStorage.isEncryptionAvailable()) { + global.store.delete(`safeStorage.${key}`); + await keytar?.deletePassword(LEGACY_KEYTAR_SERVICE, key); + await keytar?.deletePassword(KEYTAR_SERVICE, key); + return true; + } + return false; +} diff --git a/src/seshat.ts b/src/seshat.ts index 9ae970c..d200e4d 100644 --- a/src/seshat.ts +++ b/src/seshat.ts @@ -25,7 +25,7 @@ import type { } from "matrix-seshat"; // Hak dependency type import IpcMainEvent = Electron.IpcMainEvent; import { randomArray } from "./utils"; -import { keytar } from "./keytar"; +import { getPassword, setPassword } from "./safe-storage"; let seshatSupported = false; let Seshat: typeof SeshatType; @@ -51,19 +51,17 @@ 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); + try { + const storedPassphrase = await getPassword(key); + if (storedPassphrase !== null) { + return storedPassphrase; + } else { + const newPassphrase = await randomArray(32); + await setPassword(key, newPassphrase); + return newPassphrase; } + } catch (e) { + console.log("Error getting the event index passphrase out of the secret store", e); } return seshatDefaultPassphrase; } diff --git a/src/settings.ts b/src/settings.ts index 5638cca..474d2a9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -36,7 +36,7 @@ export const Settings: Record = { }, "Electron.warnBeforeExit": { async read(): Promise { - return global.store.get("warnBeforeExit", true); + return global.store.get("warnBeforeExit"); }, async write(value: any): Promise { global.store.set("warnBeforeExit", value); @@ -70,7 +70,7 @@ export const Settings: Record = { }, "Electron.enableHardwareAcceleration": { async read(): Promise { - return !global.store.get("disableHardwareAcceleration", false); + return !global.store.get("disableHardwareAcceleration"); }, async write(value: any): Promise { global.store.set("disableHardwareAcceleration", !value);