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:
parent
7f18396926
commit
cbaf40bb82
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -23,3 +23,4 @@ electronFlags:
|
||||
enableAutomaticUpdates: true
|
||||
version: 1
|
||||
vault: null
|
||||
encrypted: false
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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> {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}`
|
||||
|
@ -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 {
|
||||
|
@ -95,4 +95,8 @@ export class WebPlatformService extends PlatformService {
|
||||
return { response: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
quit (): void {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user