allow multiple private key paths - fixes #3921

This commit is contained in:
Eugene Pankov 2021-06-05 12:05:46 +02:00
parent 79a429be5d
commit a9069a4a49
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
10 changed files with 131 additions and 86 deletions

View File

@ -21,3 +21,4 @@ enableWelcomeTab: true
electronFlags: electronFlags:
- ['force_discrete_gpu', '0'] - ['force_discrete_gpu', '0']
enableAutomaticUpdates: true enableAutomaticUpdates: true
version: 1

View File

@ -8,6 +8,8 @@ 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
const LATEST_VERSION = 1
function isStructuralMember (v) { function isStructuralMember (v) {
return v instanceof Object && !(v instanceof Array) && return v instanceof Object && !(v instanceof Array) &&
Object.keys(v).length > 0 && !v.__nonStructural Object.keys(v).length > 0 && !v.__nonStructural
@ -148,8 +150,9 @@ export class ConfigService {
if (content) { if (content) {
this._store = yaml.load(content) this._store = yaml.load(content)
} else { } else {
this._store = {} this._store = { version: LATEST_VERSION }
} }
this.migrate(this._store)
this.store = new ConfigProxy(this._store, this.defaults) this.store = new ConfigProxy(this._store, this.defaults)
} }
@ -225,4 +228,17 @@ export class ConfigService {
private emitChange (): void { private emitChange (): void {
this.changed.next() this.changed.next()
} }
private migrate (config) {
config.version ??= 0
if (config.version < 1) {
for (const connection of config.ssh?.connections) {
if (connection.privateKey) {
connection.privateKeys = [connection.privateKey]
delete connection.privateKey
}
}
config.version = 1
}
}
} }

View File

@ -88,7 +88,7 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
textarea.form-control.h-100( textarea.form-control.h-100(
[(ngModel)]='configFile' [(ngModel)]='configFile'
) )
.w-100.d-flex.flex-column .w-100.d-flex.flex-column(*ngIf='showConfigDefaults')
h3 Defaults h3 Defaults
textarea.form-control.h-100( textarea.form-control.h-100(
[(ngModel)]='configDefaults', [(ngModel)]='configDefaults',
@ -102,6 +102,9 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
i.fas.fa-exclamation-triangle.mr-2 i.fas.fa-exclamation-triangle.mr-2
| Invalid syntax | Invalid syntax
button.btn.btn-secondary.ml-auto( button.btn.btn-secondary.ml-auto(
(click)='showConfigDefaults = !showConfigDefaults'
) Show defaults
button.btn.btn-secondary.ml-3(
*ngIf='platform.getConfigPath()', *ngIf='platform.getConfigPath()',
(click)='showConfigFile()' (click)='showConfigFile()'
) )

View File

@ -39,5 +39,6 @@
textarea { textarea {
font-family: 'Source Code Pro', monospace; font-family: 'Source Code Pro', monospace;
font-size: 12px;
min-height: 120px; min-height: 120px;
} }

View File

@ -30,6 +30,7 @@ export class SettingsTabComponent extends BaseTabComponent {
isShellIntegrationInstalled = false isShellIntegrationInstalled = false
checkingForUpdate = false checkingForUpdate = false
updateAvailable = false updateAvailable = false
showConfigDefaults = false
@HostBinding('class.pad-window-controls') padWindowControls = false @HostBinding('class.pad-window-controls') padWindowControls = false
constructor ( constructor (

View File

@ -39,7 +39,7 @@ export interface SSHConnection {
user: string user: string
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive' auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
password?: string password?: string
privateKey?: string privateKeys?: string[]
group: string | null group: string | null
scripts?: LoginScript[] scripts?: LoginScript[]
keepaliveInterval?: number keepaliveInterval?: number
@ -131,6 +131,11 @@ export class ForwardedPort implements ForwardedPortConfig {
} }
} }
interface AuthMethod {
type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
path?: string
}
export class SSHSession extends BaseSession { export class SSHSession extends BaseSession {
scripts?: LoginScript[] scripts?: LoginScript[]
shell?: ClientChannel shell?: ClientChannel
@ -143,9 +148,9 @@ export class SSHSession extends BaseSession {
get serviceMessage$ (): Observable<string> { return this.serviceMessage } get serviceMessage$ (): Observable<string> { return this.serviceMessage }
agentPath?: string agentPath?: string
privateKey?: string activePrivateKey: string|null = null
private authMethodsLeft: string[] = [] private remainingAuthMethods: AuthMethod[] = []
private serviceMessage = new Subject<string>() private serviceMessage = new Subject<string>()
private keychainPasswordUsed = false private keychainPasswordUsed = false
@ -189,33 +194,31 @@ export class SSHSession extends BaseSession {
this.agentPath = process.env.SSH_AUTH_SOCK! this.agentPath = process.env.SSH_AUTH_SOCK!
} }
this.authMethodsLeft = ['none'] this.remainingAuthMethods = [{ type: 'none' }]
if (!this.connection.auth || this.connection.auth === 'publicKey') { if (!this.connection.auth || this.connection.auth === 'publicKey') {
try { for (const pk of this.connection.privateKeys ?? []) {
await this.loadPrivateKey() if (await fs.exists(pk)) {
} catch (e) { this.remainingAuthMethods.push({
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`) type: 'publickey',
} path: pk,
if (!this.privateKey) { })
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`) }
} else {
this.authMethodsLeft.push('publickey')
} }
} }
if (!this.connection.auth || this.connection.auth === 'agent') { if (!this.connection.auth || this.connection.auth === 'agent') {
if (!this.agentPath) { if (!this.agentPath) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`) this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
} else { } else {
this.authMethodsLeft.push('agent') this.remainingAuthMethods.push({ type: 'agent' })
} }
} }
if (!this.connection.auth || this.connection.auth === 'password') { if (!this.connection.auth || this.connection.auth === 'password') {
this.authMethodsLeft.push('password') this.remainingAuthMethods.push({ type: 'password' })
} }
if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') { if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') {
this.authMethodsLeft.push('keyboard-interactive') this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
} }
this.authMethodsLeft.push('hostbased') this.remainingAuthMethods.push({ type: 'hostbased' })
} }
async start (): Promise<void> { async start (): Promise<void> {
@ -370,17 +373,19 @@ export class SSHSession extends BaseSession {
} }
async handleAuth (methodsLeft?: string[]): Promise<any> { async handleAuth (methodsLeft?: string[]): Promise<any> {
this.activePrivateKey = null
while (true) { while (true) {
const method = this.authMethodsLeft.shift() const method = this.remainingAuthMethods.shift()
if (!method) { if (!method) {
return false return false
} }
if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') { if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') {
// Agent can still be used even if not in methodsLeft // Agent can still be used even if not in methodsLeft
this.logger.info('Server does not support auth method', method) this.logger.info('Server does not support auth method', method.type)
continue continue
} }
if (method === 'password') { if (method.type === 'password') {
if (this.connection.password) { if (this.connection.password) {
this.emitServiceMessage('Using preset password') this.emitServiceMessage('Using preset password')
return { return {
@ -426,6 +431,19 @@ export class SSHSession extends BaseSession {
continue continue
} }
} }
if (method.type === 'publickey') {
try {
const key = await this.loadPrivateKey(method.path!)
return {
type: 'publickey',
username: this.connection.user,
key,
}
} catch (e) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.path}: ${e}`)
continue
}
}
return method return method
} }
} }
@ -560,9 +578,7 @@ export class SSHSession extends BaseSession {
} }
} }
async loadPrivateKey (): Promise<void> { async loadPrivateKey (privateKeyPath: string): Promise<string|null> {
let privateKeyPath = this.connection.privateKey
if (!privateKeyPath) { if (!privateKeyPath) {
const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa') const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
if (await fs.exists(userKeyPath)) { if (await fs.exists(userKeyPath)) {
@ -571,18 +587,21 @@ export class SSHSession extends BaseSession {
} }
} }
if (privateKeyPath) { if (!privateKeyPath) {
this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) return null
try { }
const privateKeyContents = (await fs.readFile(privateKeyPath)).toString()
const parsedKey = await this.parsePrivateKey(privateKeyContents) this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
this.privateKey = parsedKey.toString('openssh') try {
} catch (error) { const privateKeyContents = (await fs.readFile(privateKeyPath)).toString()
this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file') const parsedKey = await this.parsePrivateKey(privateKeyContents)
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`) this.activePrivateKey = parsedKey.toString('openssh')
this.notifications.error('Could not read the private key file') return this.activePrivateKey
return } catch (error) {
} this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file')
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`)
this.notifications.error('Could not read the private key file')
return null
} }
} }

View File

@ -49,30 +49,30 @@
.form-group .form-group
label Authentication method label Authentication method
.btn-group.w-100( .btn-group.mt-1.w-100(
[(ngModel)]='connection.auth', [(ngModel)]='connection.auth',
ngbRadioGroup ngbRadioGroup
) )
label.btn.btn-secondary(ngbButtonLabel) label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='null') input(type='radio', ngbButton, [value]='null')
i.far.fa-lightbulb i.far.fa-lightbulb
.m-0 Auto .m-0 Auto
label.btn.btn-secondary(ngbButtonLabel) label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"password"') input(type='radio', ngbButton, [value]='"password"')
i.fas.fa-font i.fas.fa-font
.m-0 Password .m-0 Password
label.btn.btn-secondary(ngbButtonLabel) label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"publicKey"') input(type='radio', ngbButton, [value]='"publicKey"')
i.fas.fa-key i.fas.fa-key
.m-0 Key .m-0 Key
label.btn.btn-secondary(ngbButtonLabel) label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"agent"') input(type='radio', ngbButton, [value]='"agent"')
i.fas.fa-user-secret i.fas.fa-user-secret
.m-0 Agent .m-0 Agent
label.btn.btn-secondary(ngbButtonLabel) label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"keyboardInteractive"') input(type='radio', ngbButton, [value]='"keyboardInteractive"')
i.far.fa-keyboard i.far.fa-keyboard
.m-0 Interactive .m-0 Interactive
.form-line(*ngIf='!connection.auth || connection.auth === "password"') .form-line(*ngIf='!connection.auth || connection.auth === "password"')
.header .header
@ -86,19 +86,17 @@
i.fas.fa-trash-alt i.fas.fa-trash-alt
span Forget span Forget
.form-line(*ngIf='!connection.auth || connection.auth === "publicKey"') .form-group(*ngIf='!connection.auth || connection.auth === "publicKey"')
.header label Private keys
.title Private key .list-group.mb-2
.description Path to the private key file .list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys')
.input-group i.fas.fa-key
input.form-control( .mr-auto {{path}}
type='text', button.btn.btn-link((click)='removePrivateKey(path)')
placeholder='Key file path', i.fas.fa-trash
[(ngModel)]='connection.privateKey' button.btn.btn-secondary((click)='addPrivateKey()')
) i.fas.fa-folder-open
.input-group-append span Add a private key
button.btn.btn-secondary((click)='selectPrivateKey()')
i.fas.fa-folder-open
li(ngbNavItem) li(ngbNavItem)
a(ngbNavLink) Ports a(ngbNavLink) Ports

View File

@ -67,6 +67,7 @@ export class EditConnectionModalComponent {
this.connection.algorithms = this.connection.algorithms ?? {} this.connection.algorithms = this.connection.algorithms ?? {}
this.connection.scripts = this.connection.scripts ?? [] this.connection.scripts = this.connection.scripts ?? []
this.connection.auth = this.connection.auth ?? null this.connection.auth = this.connection.auth ?? null
this.connection.privateKeys ??= []
this.useProxyCommand = !!this.connection.proxyCommand this.useProxyCommand = !!this.connection.proxyCommand
@ -101,20 +102,27 @@ export class EditConnectionModalComponent {
this.passwordStorage.deletePassword(this.connection) this.passwordStorage.deletePassword(this.connection)
} }
selectPrivateKey () { addPrivateKey () {
this.electron.dialog.showOpenDialog( this.electron.dialog.showOpenDialog(
this.hostApp.getWindow(), this.hostApp.getWindow(),
{ {
defaultPath: this.connection.privateKey, defaultPath: this.connection.privateKeys![0],
title: 'Select private key', title: 'Select private key',
} }
).then(result => { ).then(result => {
if (!result.canceled) { if (!result.canceled) {
this.connection.privateKey = result.filePaths[0] this.connection.privateKeys = [
...this.connection.privateKeys!,
...result.filePaths,
]
} }
}) })
} }
removePrivateKey (path: string) {
this.connection.privateKeys = this.connection.privateKeys?.filter(x => x !== path)
}
save () { save () {
for (const k of Object.values(SSHAlgorithmType)) { for (const k of Object.values(SSHAlgorithmType)) {
this.connection.algorithms![k] = Object.entries(this.algorithms[k]) this.connection.algorithms![k] = Object.entries(this.algorithms[k])

View File

@ -133,8 +133,6 @@ export class SSHService {
port: session.connection.port ?? 22, port: session.connection.port ?? 22,
sock: session.proxyCommandStream ?? session.jumpStream, sock: session.proxyCommandStream ?? session.jumpStream,
username: session.connection.user, username: session.connection.user,
password: session.connection.privateKey ? undefined : '',
privateKey: session.privateKey ?? undefined,
tryKeyboard: true, tryKeyboard: true,
agent: session.agentPath, agent: session.agentPath,
agentForward: session.connection.agentForward && !!session.agentPath, agentForward: session.connection.agentForward && !!session.agentPath,

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, HostAppService, Platform, PlatformService, MenuItemOptions } from 'terminus-core' import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, HostAppService, Platform, PlatformService, MenuItemOptions } from 'terminus-core'
import { SSHTabComponent } from './components/sshTab.component' import { SSHTabComponent } from './components/sshTab.component'
import { PasswordStorageService } from './services/passwordStorage.service' import { PasswordStorageService } from './services/passwordStorage.service'
import { SSHConnection } from './api' import { SSHConnection, SSHSession } from './api'
/** @hidden */ /** @hidden */
@ -40,7 +40,7 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider {
{ {
label: 'Launch WinSCP', label: 'Launch WinSCP',
click: (): void => { click: (): void => {
this.launchWinSCP(tab.connection!) this.launchWinSCP(tab.session!)
}, },
}, },
] ]
@ -60,15 +60,15 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider {
return uri return uri
} }
async launchWinSCP (connection: SSHConnection): Promise<void> { async launchWinSCP (session: SSHSession): Promise<void> {
const path = this.getPath() const path = this.getPath()
if (!path) { if (!path) {
return return
} }
const args = [await this.getURI(connection)] const args = [await this.getURI(session.connection)]
if ((!connection.auth || connection.auth === 'publicKey') && connection.privateKey) { if (session.activePrivateKey) {
args.push('/privatekey') args.push('/privatekey')
args.push(connection.privateKey) args.push(session.activePrivateKey)
} }
this.platform.exec(path, args) this.platform.exec(path, args)
} }