From cbaf40bb8258b7db655e8046c5cd1434475751e7 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sat, 5 Jun 2021 19:13:22 +0200 Subject: [PATCH] allow config encryption --- terminus-core/src/api/platform.ts | 1 + .../components/unlockVaultModal.component.ts | 2 +- terminus-core/src/configDefaults.yaml | 1 + terminus-core/src/services/config.service.ts | 79 ++++++++++++++++++- terminus-core/src/services/themes.service.ts | 3 +- terminus-core/src/services/vault.service.ts | 69 +++++++++------- .../src/services/platform.service.ts | 4 + .../components/vaultSettingsTab.component.pug | 17 +++- .../components/vaultSettingsTab.component.ts | 13 ++- .../src/services/passwordStorage.service.ts | 12 +-- terminus-web/src/platform.ts | 4 + 11 files changed, 162 insertions(+), 43 deletions(-) diff --git a/terminus-core/src/api/platform.ts b/terminus-core/src/api/platform.ts index 79d6fe40..22673c96 100644 --- a/terminus-core/src/api/platform.ts +++ b/terminus-core/src/api/platform.ts @@ -80,4 +80,5 @@ export abstract class PlatformService { abstract listFonts (): Promise abstract popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void abstract showMessageBox (options: MessageBoxOptions): Promise + abstract quit (): void } diff --git a/terminus-core/src/components/unlockVaultModal.component.ts b/terminus-core/src/components/unlockVaultModal.component.ts index 888c6131..39833332 100644 --- a/terminus-core/src/components/unlockVaultModal.component.ts +++ b/terminus-core/src/components/unlockVaultModal.component.ts @@ -16,7 +16,7 @@ export class UnlockVaultModalComponent { ) { } ngOnInit (): void { - this.rememberFor = window.localStorage.vaultRememberPassphraseFor ?? 0 + this.rememberFor = parseInt(window.localStorage.vaultRememberPassphraseFor ?? 0) setTimeout(() => { this.input.nativeElement.focus() }) diff --git a/terminus-core/src/configDefaults.yaml b/terminus-core/src/configDefaults.yaml index e2f8a4bc..cc9f7446 100644 --- a/terminus-core/src/configDefaults.yaml +++ b/terminus-core/src/configDefaults.yaml @@ -23,3 +23,4 @@ electronFlags: enableAutomaticUpdates: true version: 1 vault: null +encrypted: false diff --git a/terminus-core/src/services/config.service.ts b/terminus-core/src/services/config.service.ts index c5654d9a..4c4bc4c6 100644 --- a/terminus-core/src/services/config.service.ts +++ b/terminus-core/src/services/config.service.ts @@ -4,6 +4,7 @@ import { Injectable, Inject } from '@angular/core' import { ConfigProvider } from '../api/configProvider' import { PlatformService } from '../api/platform' import { HostAppService } from './hostApp.service' +import { Vault, VaultService } from './vault.service' const deepmerge = require('deepmerge') const configMerge = (a, b) => deepmerge(a, b, { arrayMerge: (_d, s) => s }) // eslint-disable-line @typescript-eslint/no-var-requires @@ -105,10 +106,15 @@ export class ConfigService { private constructor ( private hostApp: HostAppService, private platform: PlatformService, + private vault: VaultService, @Inject(ConfigProvider) private configProviders: ConfigProvider[], ) { this.defaults = this.mergeDefaults() - this.init() + setTimeout(() => this.init()) + vault.contentChanged$.subscribe(() => { + this.store.vault = vault.store + this.save() + }) } mergeDefaults (): unknown { @@ -152,13 +158,16 @@ export class ConfigService { } else { this._store = { version: LATEST_VERSION } } + this._store = await this.maybeDecryptConfig(this._store) this.migrate(this._store) this.store = new ConfigProxy(this._store, this.defaults) + this.vault.setStore(this.store.vault) } async save (): Promise { // Scrub undefined values - const cleanStore = JSON.parse(JSON.stringify(this._store)) + let cleanStore = JSON.parse(JSON.stringify(this._store)) + cleanStore = await this.maybeEncryptConfig(cleanStore) await this.platform.saveConfig(yaml.dump(cleanStore)) this.emitChange() this.hostApp.broadcastConfigChange(JSON.parse(JSON.stringify(this.store))) @@ -207,7 +216,7 @@ export class ConfigService { return services.filter(service => { for (const pluginName in this.servicesCache) { if (this.servicesCache[pluginName].includes(service.constructor)) { - return !this.store.pluginBlacklist.includes(pluginName) + return !this.store?.pluginBlacklist?.includes(pluginName) } } return true @@ -227,6 +236,7 @@ export class ConfigService { private emitChange (): void { this.changed.next() + this.vault.setStore(this.store.vault) } private migrate (config) { @@ -241,4 +251,67 @@ export class ConfigService { config.version = 1 } } + + private async maybeDecryptConfig (store) { + if (!store.encrypted) { + return store + } + // eslint-disable-next-line @typescript-eslint/init-declarations + let decryptedVault: Vault + while (true) { + try { + const passphrase = await this.vault.getPassphrase() + decryptedVault = await this.vault.decrypt(store.vault, passphrase) + break + } catch (e) { + let result = await this.platform.showMessageBox({ + type: 'error', + message: 'Could not decrypt config', + detail: e.toString(), + buttons: ['Try again', 'Erase config', 'Quit'], + defaultId: 0, + }) + if (result.response === 2) { + this.platform.quit() + } + if (result.response === 1) { + result = await this.platform.showMessageBox({ + type: 'warning', + message: 'Are you sure?', + detail: e.toString(), + buttons: ['Erase config', 'Quit'], + defaultId: 1, + }) + if (result.response === 1) { + this.platform.quit() + } + return {} + } + } + } + delete decryptedVault.config.vault + delete decryptedVault.config.encrypted + return { + ...decryptedVault.config, + vault: store.vault, + encrypted: store.encrypted, + } + } + + private async maybeEncryptConfig (store) { + if (!store.encrypted) { + return store + } + const vault = await this.vault.load() + if (!vault) { + throw new Error('Vault not configured') + } + vault.config = { ...store } + delete vault.config.vault + delete vault.config.encrypted + return { + vault: await this.vault.encrypt(vault), + encrypted: true, + } + } } diff --git a/terminus-core/src/services/themes.service.ts b/terminus-core/src/services/themes.service.ts index 4b1c6c7b..4b353b23 100644 --- a/terminus-core/src/services/themes.service.ts +++ b/terminus-core/src/services/themes.service.ts @@ -15,6 +15,7 @@ export class ThemesService { private config: ConfigService, @Inject(Theme) private themes: Theme[], ) { + this.applyTheme(this.findTheme('Standard')!) config.ready$.toPromise().then(() => { this.applyCurrentTheme() config.changed$.subscribe(() => { @@ -38,7 +39,7 @@ export class ThemesService { document.querySelector('head')!.appendChild(this.styleElement) } this.styleElement.textContent = theme.css - document.querySelector('style#custom-css')!.innerHTML = this.config.store.appearance.css + document.querySelector('style#custom-css')!.innerHTML = this.config.store?.appearance?.css this.themeChanged.next(theme) } diff --git a/terminus-core/src/services/vault.service.ts b/terminus-core/src/services/vault.service.ts index 53d9ebaf..47ba5a0b 100644 --- a/terminus-core/src/services/vault.service.ts +++ b/terminus-core/src/services/vault.service.ts @@ -2,8 +2,7 @@ import * as crypto from 'crypto' import { promisify } from 'util' import { Injectable, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { AsyncSubject, Observable } from 'rxjs' -import { ConfigService } from '../services/config.service' +import { AsyncSubject, Subject, Observable } from 'rxjs' import { UnlockVaultModalComponent } from '../components/unlockVaultModal.component' import { NotificationsService } from '../services/notifications.service' @@ -28,11 +27,13 @@ export interface VaultSecret { } export interface Vault { + config: any secrets: VaultSecret[] } function migrateVaultContent (content: any): Vault { return { + config: content.config, secrets: content.secrets ?? [], } } @@ -86,34 +87,27 @@ export class VaultService { /** Fires once when the config is loaded */ get ready$ (): Observable { return this.ready } - enabled = false + get contentChanged$ (): Observable { return this.contentChanged } + + store: StoredVault|null = null private ready = new AsyncSubject() + private contentChanged = new Subject() /** @hidden */ private constructor ( - private config: ConfigService, private zone: NgZone, private notifications: NotificationsService, private ngbModal: NgbModal, - ) { - config.ready$.toPromise().then(() => { - this.onConfigChange() - this.ready.next(true) - this.ready.complete() - config.changed$.subscribe(() => { - this.onConfigChange() - }) - }) - } + ) { } async setEnabled (enabled: boolean, passphrase?: string): Promise { if (enabled) { - if (!this.config.store.vault) { + if (!this.store) { await this.save(migrateVaultContent({}), passphrase) } } else { - this.config.store.vault = null - await this.config.save() + this.store = null + this.contentChanged.next() } } @@ -121,15 +115,12 @@ export class VaultService { return !!_rememberedPassphrase } - async load (passphrase?: string): Promise { - if (!this.config.store.vault) { - return null - } + async decrypt (storage: StoredVault, passphrase?: string): Promise { if (!passphrase) { passphrase = await this.getPassphrase() } try { - return await this.wrapPromise(decryptVault(this.config.store.vault, passphrase)) + return await this.wrapPromise(decryptVault(storage, passphrase)) } catch (e) { _rememberedPassphrase = null if (e.toString().includes('BAD_DECRYPT')) { @@ -139,15 +130,27 @@ export class VaultService { } } - async save (vault: Vault, passphrase?: string): Promise { + async load (passphrase?: string): Promise { + if (!this.store) { + return null + } + return this.decrypt(this.store, passphrase) + } + + async encrypt (vault: Vault, passphrase?: string): Promise { if (!passphrase) { passphrase = await this.getPassphrase() } if (_rememberedPassphrase) { _rememberedPassphrase = passphrase } - this.config.store.vault = await this.wrapPromise(encryptVault(vault, passphrase)) - await this.config.save() + return this.wrapPromise(encryptVault(vault, passphrase)) + } + + async save (vault: Vault, passphrase?: string): Promise { + await this.ready$.toPromise() + this.store = await this.encrypt(vault, passphrase) + this.contentChanged.next() } async getPassphrase (): Promise { @@ -156,7 +159,8 @@ export class VaultService { const { passphrase, rememberFor } = await modal.result setTimeout(() => { _rememberedPassphrase = null - }, rememberFor * 60000) + // avoid multiple consequent prompts + }, Math.min(1000, rememberFor * 60000)) _rememberedPassphrase = passphrase } @@ -164,6 +168,7 @@ export class VaultService { } async getSecret (type: string, key: Record): Promise { + await this.ready$.toPromise() const vault = await this.load() if (!vault) { return null @@ -172,6 +177,7 @@ export class VaultService { } async addSecret (secret: VaultSecret): Promise { + await this.ready$.toPromise() const vault = await this.load() if (!vault) { return @@ -182,6 +188,7 @@ export class VaultService { } async removeSecret (type: string, key: Record): Promise { + await this.ready$.toPromise() const vault = await this.load() if (!vault) { return @@ -194,8 +201,14 @@ export class VaultService { return Object.keys(key).every(k => secret.key[k] === key[k]) } - private onConfigChange () { - this.enabled = !!this.config.store.vault + setStore (store: StoredVault): void { + this.store = store + this.ready.next(true) + this.ready.complete() + } + + isEnabled (): boolean { + return !!this.store } private wrapPromise (promise: Promise): Promise { diff --git a/terminus-electron/src/services/platform.service.ts b/terminus-electron/src/services/platform.service.ts index 50cadad5..631bb87d 100644 --- a/terminus-electron/src/services/platform.service.ts +++ b/terminus-electron/src/services/platform.service.ts @@ -153,4 +153,8 @@ export class ElectronPlatformService extends PlatformService { async showMessageBox (options: MessageBoxOptions): Promise { return this.electron.dialog.showMessageBox(this.hostApp.getWindow(), options) } + + quit (): void { + this.electron.app.exit(0) + } } diff --git a/terminus-settings/src/components/vaultSettingsTab.component.pug b/terminus-settings/src/components/vaultSettingsTab.component.pug index ff59dac1..c2ecaaf5 100644 --- a/terminus-settings/src/components/vaultSettingsTab.component.pug +++ b/terminus-settings/src/components/vaultSettingsTab.component.pug @@ -1,13 +1,14 @@ -.text-center(*ngIf='!vault.enabled') +.text-center(*ngIf='!vault.isEnabled()') i.fas.fa-key.fa-3x.m-3 h3.m-3 Vault is not configured .m-3 Vault is an always-encrypted container for secrets such as SSH passwords and private key passphrases. button.btn.btn-primary.m-2((click)='enableVault()') Set master passphrase -div(*ngIf='vault.enabled') + +div(*ngIf='vault.isEnabled()') .d-flex.align-items-center.mb-3 h3.m-0 Vault - .d-flex.ml-auto(ngbDropdown, *ngIf='vault.enabled') + .d-flex.ml-auto(ngbDropdown, *ngIf='vault.isEnabled()') button.btn.btn-secondary(ngbDropdownToggle) Options div(ngbDropdownMenu) a(ngbDropdownItem, (click)='changePassphrase()') @@ -29,6 +30,16 @@ div(*ngIf='vault.enabled') button.btn.btn-link((click)='removeSecret(secret)') i.fas.fa-trash + h3.mt-5 Options + .form-line + .header + .title Encrypt config file + .description Puts all of Terminus configuration into the vault + toggle( + [ngModel]='config.store.encrypted', + (click)='toggleConfigEncrypted()', + ) + .text-center(*ngIf='!vaultContents') i.fas.fa-key.fa-3x h3.m-3 Vault is locked diff --git a/terminus-settings/src/components/vaultSettingsTab.component.ts b/terminus-settings/src/components/vaultSettingsTab.component.ts index 128199b4..5b5e4884 100644 --- a/terminus-settings/src/components/vaultSettingsTab.component.ts +++ b/terminus-settings/src/components/vaultSettingsTab.component.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Component } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { BaseComponent, VaultService, VaultSecret, Vault, PlatformService } from 'terminus-core' +import { BaseComponent, VaultService, VaultSecret, Vault, PlatformService, ConfigService } from 'terminus-core' import { SetVaultPassphraseModalComponent } from './setVaultPassphraseModal.component' @@ -15,6 +15,7 @@ export class VaultSettingsTabComponent extends BaseComponent { constructor ( public vault: VaultService, + public config: ConfigService, private platform: PlatformService, private ngbModal: NgbModal, ) { @@ -60,6 +61,16 @@ export class VaultSettingsTabComponent extends BaseComponent { this.vault.save(this.vaultContents, newPassphrase) } + async toggleConfigEncrypted () { + this.config.store.encrypted = !this.config.store.encrypted + try { + await this.config.save() + } catch (e) { + this.config.store.encrypted = !this.config.store.encrypted + throw e + } + } + getSecretLabel (secret: VaultSecret) { if (secret.type === 'ssh:password') { return `SSH password for ${secret.key.user}@${secret.key.host}:${secret.key.port}` diff --git a/terminus-ssh/src/services/passwordStorage.service.ts b/terminus-ssh/src/services/passwordStorage.service.ts index 743e56ec..d4212a1d 100644 --- a/terminus-ssh/src/services/passwordStorage.service.ts +++ b/terminus-ssh/src/services/passwordStorage.service.ts @@ -11,7 +11,7 @@ export class PasswordStorageService { constructor (private vault: VaultService) { } async savePassword (connection: SSHConnection, password: string): Promise { - if (this.vault.enabled) { + if (this.vault.isEnabled()) { const key = this.getVaultKeyForConnection(connection) this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSWORD, key, value: password }) } else { @@ -21,7 +21,7 @@ export class PasswordStorageService { } async deletePassword (connection: SSHConnection): Promise { - if (this.vault.enabled) { + if (this.vault.isEnabled()) { const key = this.getVaultKeyForConnection(connection) this.vault.removeSecret(VAULT_SECRET_TYPE_PASSWORD, key) } else { @@ -31,7 +31,7 @@ export class PasswordStorageService { } async loadPassword (connection: SSHConnection): Promise { - if (this.vault.enabled) { + if (this.vault.isEnabled()) { const key = this.getVaultKeyForConnection(connection) return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSWORD, key))?.value ?? null } else { @@ -41,7 +41,7 @@ export class PasswordStorageService { } async savePrivateKeyPassword (id: string, password: string): Promise { - if (this.vault.enabled) { + if (this.vault.isEnabled()) { const key = this.getVaultKeyForPrivateKey(id) this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSPHRASE, key, value: password }) } else { @@ -51,7 +51,7 @@ export class PasswordStorageService { } async deletePrivateKeyPassword (id: string): Promise { - if (this.vault.enabled) { + if (this.vault.isEnabled()) { const key = this.getVaultKeyForPrivateKey(id) this.vault.removeSecret(VAULT_SECRET_TYPE_PASSPHRASE, key) } else { @@ -61,7 +61,7 @@ export class PasswordStorageService { } async loadPrivateKeyPassword (id: string): Promise { - if (this.vault.enabled) { + if (this.vault.isEnabled()) { const key = this.getVaultKeyForPrivateKey(id) return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSPHRASE, key))?.value ?? null } else { diff --git a/terminus-web/src/platform.ts b/terminus-web/src/platform.ts index 80f8817b..3ea32b8f 100644 --- a/terminus-web/src/platform.ts +++ b/terminus-web/src/platform.ts @@ -95,4 +95,8 @@ export class WebPlatformService extends PlatformService { return { response: 0 } } } + + quit (): void { + window.close() + } }