Skip to content

Commit

Permalink
[desktop] use login keychain to secure saved credentials
Browse files Browse the repository at this point in the history
close #3733
  • Loading branch information
ganthern committed Jan 27, 2022
1 parent 7eff460 commit 83f0312
Show file tree
Hide file tree
Showing 11 changed files with 1,141 additions and 958 deletions.
25 changes: 15 additions & 10 deletions src/desktop/DesktopMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {SchedulerImpl} from "../misc/Scheduler"
import {DateProviderImpl} from "../calendar/date/CalendarUtils"
import {ThemeManager} from "./ThemeManager"
import {BuildConfigKey, DesktopConfigKey} from "./config/ConfigKeys";
import {ElectronCredentialsEncryption, ElectronCredentialsEncryptionImpl} from "./credentials/ElectronCredentialsEncryption"

mp()
type Components = {
Expand All @@ -52,6 +53,7 @@ type Components = {
readonly integrator: DesktopIntegrator
readonly tray: DesktopTray
readonly themeManager: ThemeManager
readonly credentialsEncryption: ElectronCredentialsEncryption
}
const desktopCrypto = new DesktopCryptoFacade(fs, cryptoFns)
const desktopUtils = new DesktopUtils(fs, electron, desktopCrypto)
Expand All @@ -72,19 +74,19 @@ if (opts.registerAsMailHandler && opts.unregisterAsMailHandler) {
} else if (opts.registerAsMailHandler) {
//register as mailto handler, then quit
desktopUtils.doRegisterMailtoOnWin32WithCurrentUser()
.then(() => app.exit(0))
.catch(e => {
log.error("there was a problem with registering as default mail app:", e)
app.exit(1)
})
.then(() => app.exit(0))
.catch(e => {
log.error("there was a problem with registering as default mail app:", e)
app.exit(1)
})
} else if (opts.unregisterAsMailHandler) {
//unregister as mailto handler, then quit
desktopUtils.doUnregisterMailtoOnWin32WithCurrentUser()
.then(() => app.exit(0))
.catch(e => {
log.error("there was a problem with unregistering as default mail app:", e)
app.exit(1)
})
.then(() => app.exit(0))
.catch(e => {
log.error("there was a problem with unregistering as default mail app:", e)
app.exit(1)
})
} else {
createComponents().then(startupInstance)
}
Expand All @@ -110,6 +112,7 @@ async function createComponents(): Promise<Components> {
const updater = new ElectronUpdater(conf, notifier, desktopCrypto, app, tray, new UpdaterWrapperImpl())
const shortcutManager = new LocalShortcutManager()
const themeManager = new ThemeManager(conf)
const credentialsEncryption = new ElectronCredentialsEncryptionImpl(deviceKeyProvider, desktopCrypto)
const wm = new WindowManager(conf, tray, notifier, electron, shortcutManager, dl, themeManager)
const alarmScheduler = new AlarmSchedulerImpl(dateProvider, new SchedulerImpl(dateProvider, global))
const desktopAlarmScheduler = new DesktopAlarmScheduler(wm, notifier, alarmStorage, desktopCrypto, alarmScheduler)
Expand Down Expand Up @@ -137,6 +140,7 @@ async function createComponents(): Promise<Components> {
integrator,
desktopAlarmScheduler,
themeManager,
credentialsEncryption,
)
wm.setIPC(ipc)
conf.getConst(BuildConfigKey.appUserModelId).then(appUserModelId => {
Expand All @@ -156,6 +160,7 @@ async function createComponents(): Promise<Components> {
integrator,
tray,
themeManager,
credentialsEncryption
}
}

Expand Down
71 changes: 43 additions & 28 deletions src/desktop/DeviceKeyProviderImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,64 +7,79 @@ import {defer} from "@tutao/tutanota-utils"
import {base64ToKey, keyToBase64} from "@tutao/tutanota-crypto"
// exported for testing
export const SERVICE_NAME = "tutanota-vault"
export const ACCOUNT_NAME = "tuta"

export enum KeyAccountName {
DEVICE_KEY = "tuta",
CREDENTIALS_KEY = "credentials-device-lock-key"
}

export interface DesktopDeviceKeyProvider {
getDeviceKey(): Promise<Aes256Key>

getCredentialsKey(): Promise<Aes256Key>
}

export class DeviceKeyProviderImpl implements DesktopDeviceKeyProvider {
_secretStorage: SecretStorage
_deviceKey: DeferredObject<Aes256Key>
_keyResolved: boolean = false
_resolvedKeys: Record<string, DeferredObject<Aes256Key>>
_crypto: DesktopCryptoFacade

constructor(secretStorage: SecretStorage, crypto: DesktopCryptoFacade) {
this._secretStorage = secretStorage
this._crypto = crypto
this._deviceKey = defer()
this._resolvedKeys = {}
}

/**
* get the key used to encrypt alarms and settings
*/
async getDeviceKey(): Promise<Aes256Key> {
// we want to retrieve the key exactly once
if (!this._keyResolved) {
this._keyResolved = true

this._resolveDeviceKey().then()
}
return this._resolveKey(KeyAccountName.DEVICE_KEY)
}

return this._deviceKey.promise
/**
* get the key used to encrypt saved credentials
*/
async getCredentialsKey(): Promise<Aes256Key> {
return this._resolveKey(KeyAccountName.CREDENTIALS_KEY)
}

async _resolveDeviceKey(): Promise<void> {
let storedKey: Base64 | null = null
async _resolveKey(account: KeyAccountName): Promise<Aes256Key> {

try {
storedKey = await this._secretStorage.getPassword(SERVICE_NAME, ACCOUNT_NAME)
} catch (e) {
this._deviceKey.reject(new DeviceStorageUnavailableError("could not retrieve device key from device secret storage", e))
return
}
// make sure keys are resolved exactly once
if (!this._resolvedKeys[account]) {
const deferred = defer<BitArray>()
this._resolvedKeys[account] = deferred
let storedKey: Base64 | null = null

if (storedKey) {
this._deviceKey.resolve(base64ToKey(storedKey))
} else {
try {
const newKey = await this._generateAndStoreDeviceKey()
this._deviceKey.resolve(newKey)
storedKey = await this._secretStorage.getPassword(SERVICE_NAME, account)
} catch (e) {
this._deviceKey.reject(new DeviceStorageUnavailableError("could not create new device key", e))
deferred.reject(new DeviceStorageUnavailableError(`could not retrieve key ${account} from device secret storage`, e))
}

if (storedKey) {
deferred.resolve(base64ToKey(storedKey))
} else {
try {
const newKey = await this._generateAndStoreKey(account)
deferred.resolve(newKey)
} catch (e) {
deferred.reject(new DeviceStorageUnavailableError(`could not create new ${account} key`, e))
}
}
}

return this._resolvedKeys[account].promise
}

async _generateAndStoreDeviceKey(): Promise<Aes256Key> {
log.warn("device key not found, generating a new one")
async _generateAndStoreKey(account: KeyAccountName): Promise<Aes256Key> {
log.warn(`key ${account} not found, generating a new one`)

// save key entry in keychain
const key: Aes256Key = this._crypto.generateDeviceKey()

await this._secretStorage.setPassword(SERVICE_NAME, ACCOUNT_NAME, keyToBase64(key))
await this._secretStorage.setPassword(SERVICE_NAME, account, keyToBase64(key))
return key
}
}
44 changes: 31 additions & 13 deletions src/desktop/IPC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type {WindowManager} from "./DesktopWindowManager"
import {objToError} from "../api/common/utils/Utils"
import type {DeferredObject} from "@tutao/tutanota-utils"
import {base64ToUint8Array, defer, downcast, mapNullable, noOp} from "@tutao/tutanota-utils"
import {Request, Response, RequestError} from "../api/common/MessageDispatcher"
import type {AllConfigKeys, DesktopConfig} from "./config/DesktopConfig"
import {Request, RequestError, Response} from "../api/common/MessageDispatcher"
import type {DesktopConfig} from "./config/DesktopConfig"
import type {DesktopSseClient} from "./sse/DesktopSseClient"
import type {DesktopNotifier} from "./DesktopNotifier"
import type {Socketeer} from "./Socketeer"
Expand All @@ -26,6 +26,7 @@ import type {ThemeId} from "../gui/theme"
import {ElectronExports, WebContentsEvent} from "./ElectronExportTypes";
import {DataFile} from "../api/common/DataFile";
import {Logger} from "../api/common/Logger"
import {ElectronCredentialsEncryption} from "./credentials/ElectronCredentialsEncryption"

/**
* node-side endpoint for communication between the renderer threads and the node thread
Expand All @@ -46,6 +47,7 @@ export class IPC {
readonly _err: DesktopErrorHandler
readonly _integrator: DesktopIntegrator
readonly _themeManager: ThemeManager
readonly _credentialsEncryption: ElectronCredentialsEncryption
_initialized: Array<DeferredObject<void>>
_requestId: number = 0
readonly _queue: Record<string, (...args: Array<any>) => any>
Expand All @@ -66,6 +68,7 @@ export class IPC {
integrator: DesktopIntegrator,
alarmScheduler: DesktopAlarmScheduler,
themeManager: ThemeManager,
credentialsEncryption: ElectronCredentialsEncryption
) {
this._conf = conf
this._sse = sse
Expand All @@ -82,6 +85,7 @@ export class IPC {
this._integrator = integrator
this._alarmScheduler = alarmScheduler
this._themeManager = themeManager
this._credentialsEncryption = credentialsEncryption

if (!!this._updater) {
this._updater.setUpdateDownloadedListener(() => {
Expand Down Expand Up @@ -143,14 +147,14 @@ export class IPC {

case "stopFindInPage":
return this.initialized(windowId)
.then(() => {
const w = this._wm.get(windowId)
.then(() => {
const w = this._wm.get(windowId)

if (w) {
w.stopFindInPage()
}
})
.catch(noOp)
if (w) {
w.stopFindInPage()
}
})
.catch(noOp)

case "setSearchOverlayState": {
const w = this._wm.get(windowId)
Expand Down Expand Up @@ -202,10 +206,10 @@ export class IPC {
if (args[1]) {
// open folder dialog
return this._electron.dialog
.showOpenDialog({
properties: ["openDirectory"],
})
.then(({filePaths}) => filePaths)
.showOpenDialog({
properties: ["openDirectory"],
})
.then(({filePaths}) => filePaths)
} else {
// open file
return Promise.resolve([])
Expand Down Expand Up @@ -402,6 +406,20 @@ export class IPC {
return
}

case "encryptUsingKeychain": {
const [mode, decryptedKey] = args
return this._credentialsEncryption.encryptUsingKeychain(decryptedKey, mode)
}

case "decryptUsingKeychain": {
const [mode, encryptedKey] = args
return this._credentialsEncryption.decryptUsingKeychain(encryptedKey, mode)
}

case "getSupportedEncryptionModes": {
return this._credentialsEncryption.getSupportedEncryptionModes()
}

default:
return Promise.reject(new Error(`Invalid Method invocation: ${method}`))
}
Expand Down
67 changes: 67 additions & 0 deletions src/desktop/credentials/ElectronCredentialsEncryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {CredentialEncryptionMode} from "../../misc/credentials/CredentialEncryptionMode"
import {ProgrammingError} from "../../api/common/error/ProgrammingError"
import {DesktopDeviceKeyProvider} from "../DeviceKeyProviderImpl"
import {DesktopCryptoFacade} from "../DesktopCryptoFacade"

export interface ElectronCredentialsEncryption {
/**
* Decrypts arbitrary data using keychain keys, prompting for authentication if needed.
*/
decryptUsingKeychain(base64EncodedEncryptedData: string, encryptionMode: CredentialEncryptionMode): Promise<string>

/**
* Encrypts arbitrary data using keychain keys, prompting for authentication if needed.
*/
encryptUsingKeychain(base64EncodedData: string, encryptionMode: CredentialEncryptionMode): Promise<string>

getSupportedEncryptionModes(): Promise<Array<CredentialEncryptionMode>>

}

export class ElectronCredentialsEncryptionImpl implements ElectronCredentialsEncryption {

private readonly _desktopDeviceKeyProvider: DesktopDeviceKeyProvider
private readonly _crypto: DesktopCryptoFacade

constructor(deviceKeyProvider: DesktopDeviceKeyProvider, crypto: DesktopCryptoFacade) {
this._desktopDeviceKeyProvider = deviceKeyProvider
this._crypto = crypto
}

async decryptUsingKeychain(base64EncodedEncryptedData: string, encryptionMode: CredentialEncryptionMode): Promise<string> {
if (encryptionMode !== CredentialEncryptionMode.DEVICE_LOCK) {
throw new ProgrammingError("should not use unsupported encryption mode")
}
const key = await this._desktopDeviceKeyProvider.getCredentialsKey()
const decryptedData = this._crypto.aes256DecryptKeyToB64(key, base64EncodedEncryptedData)
return Promise.resolve(decryptedData)
}

async encryptUsingKeychain(base64EncodedData: string, encryptionMode: CredentialEncryptionMode): Promise<string> {
if (encryptionMode !== CredentialEncryptionMode.DEVICE_LOCK) {
throw new ProgrammingError("should not use unsupported encryption mode")
}
const key = await this._desktopDeviceKeyProvider.getCredentialsKey()
const encryptedData = this._crypto.aes256EncryptKeyToB64(key, base64EncodedData)
return Promise.resolve(encryptedData)
}

getSupportedEncryptionModes(): Promise<Array<CredentialEncryptionMode>> {
return Promise.resolve([CredentialEncryptionMode.DEVICE_LOCK])
}

}

export class ElectronCredentialsEncryptionStub implements ElectronCredentialsEncryption {
decryptUsingKeychain(base64EncodedEncryptedData: string, encryptionMode: CredentialEncryptionMode): Promise<string> {
return Promise.resolve(base64EncodedEncryptedData)
}

encryptUsingKeychain(base64EncodedData: string, encryptionMode: CredentialEncryptionMode): Promise<string> {
return Promise.resolve(base64EncodedData)
}

getSupportedEncryptionModes(): Promise<Array<CredentialEncryptionMode>> {
return Promise.resolve([CredentialEncryptionMode.DEVICE_LOCK])
}
}
4 changes: 2 additions & 2 deletions src/misc/credentials/CredentialsProviderFactory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {CredentialsEncryption, ICredentialsProvider, PersistentCredentials} from "./CredentialsProvider"
import {CredentialsProvider} from "./CredentialsProvider"
import {deviceConfig} from "../DeviceConfig"
import {isAdminClient, isApp, isDesktop} from "../../api/common/Env"
import {isApp, isDesktop} from "../../api/common/Env"
import type {DeviceEncryptionFacade} from "../../api/worker/facades/DeviceEncryptionFacade"
import {CredentialsKeyMigrator, CredentialsKeyMigratorStub} from "./CredentialsKeyMigrator"
import {CredentialsKeyProvider} from "./CredentialsKeyProvider"
Expand All @@ -11,7 +11,7 @@ import type {NativeInterface} from "../../native/common/NativeInterface"
import {assertNotNull} from "@tutao/tutanota-utils"

export function usingKeychainAuthentication(): boolean {
return isApp()
return isApp() || isDesktop()
}

/**
Expand Down
3 changes: 3 additions & 0 deletions test/api/TestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export function makeDeviceKeyProvider(uint8ArrayKey: Uint8Array): DesktopDeviceK
return {
getDeviceKey() {
return Promise.resolve(uint8ArrayToKey(uint8ArrayKey))
},
getCredentialsKey(): Promise<Aes256Key> {
return Promise.resolve(uint8ArrayToKey(uint8ArrayKey))
}
}
}
1 change: 1 addition & 0 deletions test/client/Suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {preTest, reportTest} from "../api/TestUtils"
await import("./desktop/DesktopContextMenuTest.js")
await import("./desktop/DeviceKeyProviderTest.js")
await import ("./desktop/config/ConfigFileTest.js")
await import ("./desktop/credentials/ElectronCredentialsEncryptionTest")
}

preTest()
Expand Down
Loading

0 comments on commit 83f0312

Please sign in to comment.