Browse Source

allow config encryption

Eugene Pankov 4 years ago
parent
commit
cbaf40bb82

+ 1 - 0
terminus-core/src/api/platform.ts

@@ -80,4 +80,5 @@ export abstract class PlatformService {
     abstract listFonts (): Promise<string[]>
     abstract popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void
     abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
+    abstract quit (): void
 }

+ 1 - 1
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()
         })

+ 1 - 0
terminus-core/src/configDefaults.yaml

@@ -23,3 +23,4 @@ electronFlags:
 enableAutomaticUpdates: true
 version: 1
 vault: null
+encrypted: false

+ 76 - 3
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<void> {
         // 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,
+        }
+    }
 }

+ 2 - 1
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)
     }
 

+ 41 - 28
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<boolean> { return this.ready }
 
-    enabled = false
+    get contentChanged$ (): Observable<void> { return this.contentChanged }
+
+    store: StoredVault|null = null
     private ready = new AsyncSubject<boolean>()
+    private contentChanged = new Subject<void>()
 
     /** @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<void> {
         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<Vault|null> {
-        if (!this.config.store.vault) {
-            return null
-        }
+    async decrypt (storage: StoredVault, passphrase?: string): Promise<Vault> {
         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<void> {
+    async load (passphrase?: string): Promise<Vault|null> {
+        if (!this.store) {
+            return null
+        }
+        return this.decrypt(this.store, passphrase)
+    }
+
+    async encrypt (vault: Vault, passphrase?: string): Promise<StoredVault|null> {
         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<void> {
+        await this.ready$.toPromise()
+        this.store = await this.encrypt(vault, passphrase)
+        this.contentChanged.next()
     }
 
     async getPassphrase (): Promise<string> {
@@ -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<string, any>): Promise<VaultSecret|null> {
+        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<void> {
+        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<string, any>): Promise<void> {
+        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 <T> (promise: Promise<T>): Promise<T> {

+ 4 - 0
terminus-electron/src/services/platform.service.ts

@@ -153,4 +153,8 @@ export class ElectronPlatformService extends PlatformService {
     async showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult> {
         return this.electron.dialog.showMessageBox(this.hostApp.getWindow(), options)
     }
+
+    quit (): void {
+        this.electron.app.exit(0)
+    }
 }

+ 14 - 3
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

+ 12 - 1
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}`

+ 6 - 6
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<void> {
-        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<void> {
-        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<string|null> {
-        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<void> {
-        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<void> {
-        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<string|null> {
-        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 {

+ 4 - 0
terminus-web/src/platform.ts

@@ -95,4 +95,8 @@ export class WebPlatformService extends PlatformService {
             return { response: 0 }
         }
     }
+
+    quit (): void {
+        window.close()
+    }
 }