1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-12-24 19:13:31 +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 popupContextMenu (menu: MenuItemOptions[], event?: MouseEvent): void
abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
abstract quit (): void
}

View File

@ -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()
})

View File

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

View File

@ -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,
}
}
}

View File

@ -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)
}

View File

@ -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> {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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}`

View File

@ -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 {

View File

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