Skip to content

Commit

Permalink
Add code to migrate from keytar to safeStorage
Browse files Browse the repository at this point in the history
Translated to TypeScript from PR #3742
  • Loading branch information
ganthern authored and rezbyte committed Nov 22, 2023
1 parent 43acfdc commit cf70ee6
Show file tree
Hide file tree
Showing 2 changed files with 96 additions and 2 deletions.
6 changes: 4 additions & 2 deletions src/desktop/DesktopMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { DesktopTray } from "./tray/DesktopTray"
import { log } from "./DesktopLog"
import { UpdaterWrapper } from "./UpdaterWrapper"
import { ElectronNotificationFactory } from "./NotificatonFactory"
import { KeytarSecretStorage } from "./sse/SecretStorage"
import { KeytarSecretStorage, SafeStorageSecretStorage } from "./sse/SecretStorage"
import fs from "node:fs"
import { DesktopIntegrator, getDesktopIntegratorForPlatform } from "./integration/DesktopIntegrator"
import net from "node:net"
Expand Down Expand Up @@ -131,7 +131,8 @@ async function createComponents(): Promise<Components> {
const en = (await import("../translations/en.js")).default
lang.init(en)
const secretStorage = new KeytarSecretStorage()
const keyStoreFacade = new KeyStoreFacadeImpl(secretStorage, desktopCrypto)
const safeStorageSecretStorage = new SafeStorageSecretStorage(electron, fs, path, secretStorage)
const keyStoreFacade = new KeyStoreFacadeImpl(safeStorageSecretStorage, desktopCrypto)
const configMigrator = new DesktopConfigMigrator(desktopCrypto, keyStoreFacade, electron)
const conf = new DesktopConfig(configMigrator, keyStoreFacade, desktopCrypto)
// Fire config loading, dont wait for it
Expand Down Expand Up @@ -279,6 +280,7 @@ async function startupInstance(components: Components) {
}

async function onAppReady(components: Components) {
// earliest point for safeStorage
const { wm, keyStoreFacade, conf } = components
keyStoreFacade.getDeviceKey().catch(() => {
electron.dialog.showErrorBox("Could not access secret storage", "Please see the FAQ at tuta.com/faq/#secretstorage")
Expand Down
92 changes: 92 additions & 0 deletions src/desktop/sse/SecretStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { default as keytar } from "keytar"
import { CancelledError } from "../../api/common/error/CancelledError"
import { noOp } from "@tutao/tutanota-utils"
import * as PathModule from "node:path"
import * as FsModule from "node:fs"
import { DeviceStorageUnavailableError } from "../../api/common/error/DeviceStorageUnavailableError.js"

const { CANCELLED, getPassword, setPassword } = keytar

Expand Down Expand Up @@ -38,3 +41,92 @@ export class KeytarSecretStorage implements SecretStorage {
return newOp
}
}

/**
* Secret Storage impl using the electron 15+ SafeStorage API
*
* Note: the main thread will be blocked while the keychain is being unlocked,
* potentially for as long as the user takes to enter a password.
* We're asking for access before any windows are created, which should prevent
* any weirdness arising from that.
*/
export class SafeStorageSecretStorage implements SecretStorage {
constructor(
private readonly electron: typeof Electron.CrossProcessExports,
private readonly fs: typeof FsModule,
private readonly path: typeof PathModule,
private readonly keytarSecretStorage: KeytarSecretStorage,
) {}

async getPassword(service: string, account: string): Promise<string | null> {
await this.assertAvailable()
await this.migrateKeytarPassword(service, account)
const keyPath = this.getKeyPath(service, account)
try {
const encPwBuffer = await this.fs.promises.readFile(keyPath)
const plainPw = this.electron.safeStorage.decryptString(encPwBuffer)
return Promise.resolve(plainPw)
} catch (e) {
if (e.code === "ENOENT") return null
throw e
}
}

async setPassword(service: string, account: string, password: string): Promise<void> {
await this.assertAvailable()
const keyPath = this.getKeyPath(service, account)
const cypherBuffer = this.electron.safeStorage.encryptString(password)
return this.fs.promises.writeFile(keyPath, cypherBuffer)
}

private getKeyPath(service: string, account: string): string {
const fname = service.concat("-", account)
const safeStoragePath = this.getSafeStoragePath()
return this.path.join(safeStoragePath, fname)
}

/**
* this should always be a path inside the user's home directory (or equivalent)
* @private
*/
private getSafeStoragePath(): string {
return this.path.join(this.electron.app.getPath("userData"), "safe_storage")
}

/**
* ensures that the safe_storage directory exists and that we can use the
* safeStorage API
* @private
*/
private async assertAvailable(): Promise<void> {
await this.fs.promises.mkdir(this.getSafeStoragePath(), { recursive: true })
// see https://github.com/electron/electron/issues/32206
// the rest of the safeStorage API should be throwing errors
// we can catch until this works.
if (process.platform === "linux") return
if (this.electron.safeStorage.isEncryptionAvailable()) return
throw new DeviceStorageUnavailableError("safeStorage API is not available", null)
}

/**
* most devices will have stored a deviceKey with keytar, which we can move
* to the safeStorage impl.
*
* @private
*/
private async migrateKeytarPassword(service: string, account: string): Promise<void> {
let keytarPw = null
try {
keytarPw = await this.keytarSecretStorage.getPassword(service, account)
} catch (e) {
console.debug("keytar failed, assuming there's no pw stored")
}
if (keytarPw) {
await this.setPassword(service, account, keytarPw)
// do not do this until later. there may be multiple installs using
// the deviceKey if for some reason keytar used a system keychain
// to store it.
// await this._keytarSecretStorage.deletePassword(service, account)
}
}
}

0 comments on commit cf70ee6

Please sign in to comment.