1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-12-25 03:22:58 +03:00

allow config encryption

This commit is contained in:
Eugene Pankov 2021-06-05 19:13:22 +02:00
parent 7f18396926
commit cbaf40bb82
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
11 changed files with 162 additions and 43 deletions

View File

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

View File

@ -16,7 +16,7 @@ export class UnlockVaultModalComponent {
) { } ) { }
ngOnInit (): void { ngOnInit (): void {
this.rememberFor = window.localStorage.vaultRememberPassphraseFor ?? 0 this.rememberFor = parseInt(window.localStorage.vaultRememberPassphraseFor ?? 0)
setTimeout(() => { setTimeout(() => {
this.input.nativeElement.focus() this.input.nativeElement.focus()
}) })

View File

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

View File

@ -4,6 +4,7 @@ import { Injectable, Inject } from '@angular/core'
import { ConfigProvider } from '../api/configProvider' import { ConfigProvider } from '../api/configProvider'
import { PlatformService } from '../api/platform' import { PlatformService } from '../api/platform'
import { HostAppService } from './hostApp.service' import { HostAppService } from './hostApp.service'
import { Vault, VaultService } from './vault.service'
const deepmerge = require('deepmerge') const deepmerge = require('deepmerge')
const configMerge = (a, b) => deepmerge(a, b, { arrayMerge: (_d, s) => s }) // eslint-disable-line @typescript-eslint/no-var-requires 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 constructor (
private hostApp: HostAppService, private hostApp: HostAppService,
private platform: PlatformService, private platform: PlatformService,
private vault: VaultService,
@Inject(ConfigProvider) private configProviders: ConfigProvider[], @Inject(ConfigProvider) private configProviders: ConfigProvider[],
) { ) {
this.defaults = this.mergeDefaults() this.defaults = this.mergeDefaults()
this.init() setTimeout(() => this.init())
vault.contentChanged$.subscribe(() => {
this.store.vault = vault.store
this.save()
})
} }
mergeDefaults (): unknown { mergeDefaults (): unknown {
@ -152,13 +158,16 @@ export class ConfigService {
} else { } else {
this._store = { version: LATEST_VERSION } this._store = { version: LATEST_VERSION }
} }
this._store = await this.maybeDecryptConfig(this._store)
this.migrate(this._store) this.migrate(this._store)
this.store = new ConfigProxy(this._store, this.defaults) this.store = new ConfigProxy(this._store, this.defaults)
this.vault.setStore(this.store.vault)
} }
async save (): Promise<void> { async save (): Promise<void> {
// Scrub undefined values // 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)) await this.platform.saveConfig(yaml.dump(cleanStore))
this.emitChange() this.emitChange()
this.hostApp.broadcastConfigChange(JSON.parse(JSON.stringify(this.store))) this.hostApp.broadcastConfigChange(JSON.parse(JSON.stringify(this.store)))
@ -207,7 +216,7 @@ export class ConfigService {
return services.filter(service => { return services.filter(service => {
for (const pluginName in this.servicesCache) { for (const pluginName in this.servicesCache) {
if (this.servicesCache[pluginName].includes(service.constructor)) { if (this.servicesCache[pluginName].includes(service.constructor)) {
return !this.store.pluginBlacklist.includes(pluginName) return !this.store?.pluginBlacklist?.includes(pluginName)
} }
} }
return true return true
@ -227,6 +236,7 @@ export class ConfigService {
private emitChange (): void { private emitChange (): void {
this.changed.next() this.changed.next()
this.vault.setStore(this.store.vault)
} }
private migrate (config) { private migrate (config) {
@ -241,4 +251,67 @@ export class ConfigService {
config.version = 1 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,
}
}
} }

View File

@ -15,6 +15,7 @@ export class ThemesService {
private config: ConfigService, private config: ConfigService,
@Inject(Theme) private themes: Theme[], @Inject(Theme) private themes: Theme[],
) { ) {
this.applyTheme(this.findTheme('Standard')!)
config.ready$.toPromise().then(() => { config.ready$.toPromise().then(() => {
this.applyCurrentTheme() this.applyCurrentTheme()
config.changed$.subscribe(() => { config.changed$.subscribe(() => {
@ -38,7 +39,7 @@ export class ThemesService {
document.querySelector('head')!.appendChild(this.styleElement) document.querySelector('head')!.appendChild(this.styleElement)
} }
this.styleElement.textContent = theme.css 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) this.themeChanged.next(theme)
} }

View File

@ -2,8 +2,7 @@ import * as crypto from 'crypto'
import { promisify } from 'util' import { promisify } from 'util'
import { Injectable, NgZone } from '@angular/core' import { Injectable, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { AsyncSubject, Observable } from 'rxjs' import { AsyncSubject, Subject, Observable } from 'rxjs'
import { ConfigService } from '../services/config.service'
import { UnlockVaultModalComponent } from '../components/unlockVaultModal.component' import { UnlockVaultModalComponent } from '../components/unlockVaultModal.component'
import { NotificationsService } from '../services/notifications.service' import { NotificationsService } from '../services/notifications.service'
@ -28,11 +27,13 @@ export interface VaultSecret {
} }
export interface Vault { export interface Vault {
config: any
secrets: VaultSecret[] secrets: VaultSecret[]
} }
function migrateVaultContent (content: any): Vault { function migrateVaultContent (content: any): Vault {
return { return {
config: content.config,
secrets: content.secrets ?? [], secrets: content.secrets ?? [],
} }
} }
@ -86,34 +87,27 @@ export class VaultService {
/** Fires once when the config is loaded */ /** Fires once when the config is loaded */
get ready$ (): Observable<boolean> { return this.ready } 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 ready = new AsyncSubject<boolean>()
private contentChanged = new Subject<void>()
/** @hidden */ /** @hidden */
private constructor ( private constructor (
private config: ConfigService,
private zone: NgZone, private zone: NgZone,
private notifications: NotificationsService, private notifications: NotificationsService,
private ngbModal: NgbModal, 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> { async setEnabled (enabled: boolean, passphrase?: string): Promise<void> {
if (enabled) { if (enabled) {
if (!this.config.store.vault) { if (!this.store) {
await this.save(migrateVaultContent({}), passphrase) await this.save(migrateVaultContent({}), passphrase)
} }
} else { } else {
this.config.store.vault = null this.store = null
await this.config.save() this.contentChanged.next()
} }
} }
@ -121,15 +115,12 @@ export class VaultService {
return !!_rememberedPassphrase return !!_rememberedPassphrase
} }
async load (passphrase?: string): Promise<Vault|null> { async decrypt (storage: StoredVault, passphrase?: string): Promise<Vault> {
if (!this.config.store.vault) {
return null
}
if (!passphrase) { if (!passphrase) {
passphrase = await this.getPassphrase() passphrase = await this.getPassphrase()
} }
try { try {
return await this.wrapPromise(decryptVault(this.config.store.vault, passphrase)) return await this.wrapPromise(decryptVault(storage, passphrase))
} catch (e) { } catch (e) {
_rememberedPassphrase = null _rememberedPassphrase = null
if (e.toString().includes('BAD_DECRYPT')) { 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) { if (!passphrase) {
passphrase = await this.getPassphrase() passphrase = await this.getPassphrase()
} }
if (_rememberedPassphrase) { if (_rememberedPassphrase) {
_rememberedPassphrase = passphrase _rememberedPassphrase = passphrase
} }
this.config.store.vault = await this.wrapPromise(encryptVault(vault, passphrase)) return this.wrapPromise(encryptVault(vault, passphrase))
await this.config.save() }
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> { async getPassphrase (): Promise<string> {
@ -156,7 +159,8 @@ export class VaultService {
const { passphrase, rememberFor } = await modal.result const { passphrase, rememberFor } = await modal.result
setTimeout(() => { setTimeout(() => {
_rememberedPassphrase = null _rememberedPassphrase = null
}, rememberFor * 60000) // avoid multiple consequent prompts
}, Math.min(1000, rememberFor * 60000))
_rememberedPassphrase = passphrase _rememberedPassphrase = passphrase
} }
@ -164,6 +168,7 @@ export class VaultService {
} }
async getSecret (type: string, key: Record<string, any>): Promise<VaultSecret|null> { async getSecret (type: string, key: Record<string, any>): Promise<VaultSecret|null> {
await this.ready$.toPromise()
const vault = await this.load() const vault = await this.load()
if (!vault) { if (!vault) {
return null return null
@ -172,6 +177,7 @@ export class VaultService {
} }
async addSecret (secret: VaultSecret): Promise<void> { async addSecret (secret: VaultSecret): Promise<void> {
await this.ready$.toPromise()
const vault = await this.load() const vault = await this.load()
if (!vault) { if (!vault) {
return return
@ -182,6 +188,7 @@ export class VaultService {
} }
async removeSecret (type: string, key: Record<string, any>): Promise<void> { async removeSecret (type: string, key: Record<string, any>): Promise<void> {
await this.ready$.toPromise()
const vault = await this.load() const vault = await this.load()
if (!vault) { if (!vault) {
return return
@ -194,8 +201,14 @@ export class VaultService {
return Object.keys(key).every(k => secret.key[k] === key[k]) return Object.keys(key).every(k => secret.key[k] === key[k])
} }
private onConfigChange () { setStore (store: StoredVault): void {
this.enabled = !!this.config.store.vault this.store = store
this.ready.next(true)
this.ready.complete()
}
isEnabled (): boolean {
return !!this.store
} }
private wrapPromise <T> (promise: Promise<T>): Promise<T> { private wrapPromise <T> (promise: Promise<T>): Promise<T> {

View File

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

View File

@ -1,13 +1,14 @@
.text-center(*ngIf='!vault.enabled') .text-center(*ngIf='!vault.isEnabled()')
i.fas.fa-key.fa-3x.m-3 i.fas.fa-key.fa-3x.m-3
h3.m-3 Vault is not configured 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. .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 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 .d-flex.align-items-center.mb-3
h3.m-0 Vault 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 button.btn.btn-secondary(ngbDropdownToggle) Options
div(ngbDropdownMenu) div(ngbDropdownMenu)
a(ngbDropdownItem, (click)='changePassphrase()') a(ngbDropdownItem, (click)='changePassphrase()')
@ -29,6 +30,16 @@ div(*ngIf='vault.enabled')
button.btn.btn-link((click)='removeSecret(secret)') button.btn.btn-link((click)='removeSecret(secret)')
i.fas.fa-trash 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') .text-center(*ngIf='!vaultContents')
i.fas.fa-key.fa-3x i.fas.fa-key.fa-3x
h3.m-3 Vault is locked h3.m-3 Vault is locked

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 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' import { SetVaultPassphraseModalComponent } from './setVaultPassphraseModal.component'
@ -15,6 +15,7 @@ export class VaultSettingsTabComponent extends BaseComponent {
constructor ( constructor (
public vault: VaultService, public vault: VaultService,
public config: ConfigService,
private platform: PlatformService, private platform: PlatformService,
private ngbModal: NgbModal, private ngbModal: NgbModal,
) { ) {
@ -60,6 +61,16 @@ export class VaultSettingsTabComponent extends BaseComponent {
this.vault.save(this.vaultContents, newPassphrase) 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) { getSecretLabel (secret: VaultSecret) {
if (secret.type === 'ssh:password') { if (secret.type === 'ssh:password') {
return `SSH password for ${secret.key.user}@${secret.key.host}:${secret.key.port}` return `SSH password for ${secret.key.user}@${secret.key.host}:${secret.key.port}`

View File

@ -11,7 +11,7 @@ export class PasswordStorageService {
constructor (private vault: VaultService) { } constructor (private vault: VaultService) { }
async savePassword (connection: SSHConnection, password: string): Promise<void> { async savePassword (connection: SSHConnection, password: string): Promise<void> {
if (this.vault.enabled) { if (this.vault.isEnabled()) {
const key = this.getVaultKeyForConnection(connection) const key = this.getVaultKeyForConnection(connection)
this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSWORD, key, value: password }) this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSWORD, key, value: password })
} else { } else {
@ -21,7 +21,7 @@ export class PasswordStorageService {
} }
async deletePassword (connection: SSHConnection): Promise<void> { async deletePassword (connection: SSHConnection): Promise<void> {
if (this.vault.enabled) { if (this.vault.isEnabled()) {
const key = this.getVaultKeyForConnection(connection) const key = this.getVaultKeyForConnection(connection)
this.vault.removeSecret(VAULT_SECRET_TYPE_PASSWORD, key) this.vault.removeSecret(VAULT_SECRET_TYPE_PASSWORD, key)
} else { } else {
@ -31,7 +31,7 @@ export class PasswordStorageService {
} }
async loadPassword (connection: SSHConnection): Promise<string|null> { async loadPassword (connection: SSHConnection): Promise<string|null> {
if (this.vault.enabled) { if (this.vault.isEnabled()) {
const key = this.getVaultKeyForConnection(connection) const key = this.getVaultKeyForConnection(connection)
return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSWORD, key))?.value ?? null return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSWORD, key))?.value ?? null
} else { } else {
@ -41,7 +41,7 @@ export class PasswordStorageService {
} }
async savePrivateKeyPassword (id: string, password: string): Promise<void> { async savePrivateKeyPassword (id: string, password: string): Promise<void> {
if (this.vault.enabled) { if (this.vault.isEnabled()) {
const key = this.getVaultKeyForPrivateKey(id) const key = this.getVaultKeyForPrivateKey(id)
this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSPHRASE, key, value: password }) this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSPHRASE, key, value: password })
} else { } else {
@ -51,7 +51,7 @@ export class PasswordStorageService {
} }
async deletePrivateKeyPassword (id: string): Promise<void> { async deletePrivateKeyPassword (id: string): Promise<void> {
if (this.vault.enabled) { if (this.vault.isEnabled()) {
const key = this.getVaultKeyForPrivateKey(id) const key = this.getVaultKeyForPrivateKey(id)
this.vault.removeSecret(VAULT_SECRET_TYPE_PASSPHRASE, key) this.vault.removeSecret(VAULT_SECRET_TYPE_PASSPHRASE, key)
} else { } else {
@ -61,7 +61,7 @@ export class PasswordStorageService {
} }
async loadPrivateKeyPassword (id: string): Promise<string|null> { async loadPrivateKeyPassword (id: string): Promise<string|null> {
if (this.vault.enabled) { if (this.vault.isEnabled()) {
const key = this.getVaultKeyForPrivateKey(id) const key = this.getVaultKeyForPrivateKey(id)
return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSPHRASE, key))?.value ?? null return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSPHRASE, key))?.value ?? null
} else { } else {

View File

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