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:
- ['force_discrete_gpu', '0']
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 LATEST_VERSION = 1
function isStructuralMember (v) {
return v instanceof Object && !(v instanceof Array) &&
Object.keys(v).length > 0 && !v.__nonStructural
@ -148,8 +150,9 @@ export class ConfigService {
if (content) {
this._store = yaml.load(content)
} else {
this._store = {}
this._store = { version: LATEST_VERSION }
}
this.migrate(this._store)
this.store = new ConfigProxy(this._store, this.defaults)
}
@ -225,4 +228,17 @@ export class ConfigService {
private emitChange (): void {
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(
[(ngModel)]='configFile'
)
.w-100.d-flex.flex-column
.w-100.d-flex.flex-column(*ngIf='showConfigDefaults')
h3 Defaults
textarea.form-control.h-100(
[(ngModel)]='configDefaults',
@ -102,6 +102,9 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
i.fas.fa-exclamation-triangle.mr-2
| Invalid syntax
button.btn.btn-secondary.ml-auto(
(click)='showConfigDefaults = !showConfigDefaults'
) Show defaults
button.btn.btn-secondary.ml-3(
*ngIf='platform.getConfigPath()',
(click)='showConfigFile()'
)

View File

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

View File

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

View File

@ -39,7 +39,7 @@ export interface SSHConnection {
user: string
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
password?: string
privateKey?: string
privateKeys?: string[]
group: string | null
scripts?: LoginScript[]
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 {
scripts?: LoginScript[]
shell?: ClientChannel
@ -143,9 +148,9 @@ export class SSHSession extends BaseSession {
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
agentPath?: string
privateKey?: string
activePrivateKey: string|null = null
private authMethodsLeft: string[] = []
private remainingAuthMethods: AuthMethod[] = []
private serviceMessage = new Subject<string>()
private keychainPasswordUsed = false
@ -189,33 +194,31 @@ export class SSHSession extends BaseSession {
this.agentPath = process.env.SSH_AUTH_SOCK!
}
this.authMethodsLeft = ['none']
this.remainingAuthMethods = [{ type: 'none' }]
if (!this.connection.auth || this.connection.auth === 'publicKey') {
try {
await this.loadPrivateKey()
} catch (e) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`)
}
if (!this.privateKey) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`)
} else {
this.authMethodsLeft.push('publickey')
for (const pk of this.connection.privateKeys ?? []) {
if (await fs.exists(pk)) {
this.remainingAuthMethods.push({
type: 'publickey',
path: pk,
})
}
}
}
if (!this.connection.auth || this.connection.auth === 'agent') {
if (!this.agentPath) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
} else {
this.authMethodsLeft.push('agent')
this.remainingAuthMethods.push({ type: 'agent' })
}
}
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') {
this.authMethodsLeft.push('keyboard-interactive')
this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
}
this.authMethodsLeft.push('hostbased')
this.remainingAuthMethods.push({ type: 'hostbased' })
}
async start (): Promise<void> {
@ -370,17 +373,19 @@ export class SSHSession extends BaseSession {
}
async handleAuth (methodsLeft?: string[]): Promise<any> {
this.activePrivateKey = null
while (true) {
const method = this.authMethodsLeft.shift()
const method = this.remainingAuthMethods.shift()
if (!method) {
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
this.logger.info('Server does not support auth method', method)
this.logger.info('Server does not support auth method', method.type)
continue
}
if (method === 'password') {
if (method.type === 'password') {
if (this.connection.password) {
this.emitServiceMessage('Using preset password')
return {
@ -426,6 +431,19 @@ export class SSHSession extends BaseSession {
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
}
}
@ -560,9 +578,7 @@ export class SSHSession extends BaseSession {
}
}
async loadPrivateKey (): Promise<void> {
let privateKeyPath = this.connection.privateKey
async loadPrivateKey (privateKeyPath: string): Promise<string|null> {
if (!privateKeyPath) {
const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
if (await fs.exists(userKeyPath)) {
@ -571,18 +587,21 @@ export class SSHSession extends BaseSession {
}
}
if (privateKeyPath) {
this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
try {
const privateKeyContents = (await fs.readFile(privateKeyPath)).toString()
const parsedKey = await this.parsePrivateKey(privateKeyContents)
this.privateKey = parsedKey.toString('openssh')
} 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
}
if (!privateKeyPath) {
return null
}
this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
try {
const privateKeyContents = (await fs.readFile(privateKeyPath)).toString()
const parsedKey = await this.parsePrivateKey(privateKeyContents)
this.activePrivateKey = parsedKey.toString('openssh')
return this.activePrivateKey
} 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
label Authentication method
.btn-group.w-100(
[(ngModel)]='connection.auth',
ngbRadioGroup
)
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='null')
i.far.fa-lightbulb
.m-0 Auto
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"password"')
i.fas.fa-font
.m-0 Password
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"publicKey"')
i.fas.fa-key
.m-0 Key
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"agent"')
i.fas.fa-user-secret
.m-0 Agent
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"keyboardInteractive"')
i.far.fa-keyboard
.m-0 Interactive
.btn-group.mt-1.w-100(
[(ngModel)]='connection.auth',
ngbRadioGroup
)
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='null')
i.far.fa-lightbulb
.m-0 Auto
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"password"')
i.fas.fa-font
.m-0 Password
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"publicKey"')
i.fas.fa-key
.m-0 Key
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"agent"')
i.fas.fa-user-secret
.m-0 Agent
label.btn.btn-secondary(ngbButtonLabel)
input(type='radio', ngbButton, [value]='"keyboardInteractive"')
i.far.fa-keyboard
.m-0 Interactive
.form-line(*ngIf='!connection.auth || connection.auth === "password"')
.header
@ -86,19 +86,17 @@
i.fas.fa-trash-alt
span Forget
.form-line(*ngIf='!connection.auth || connection.auth === "publicKey"')
.header
.title Private key
.description Path to the private key file
.input-group
input.form-control(
type='text',
placeholder='Key file path',
[(ngModel)]='connection.privateKey'
)
.input-group-append
button.btn.btn-secondary((click)='selectPrivateKey()')
i.fas.fa-folder-open
.form-group(*ngIf='!connection.auth || connection.auth === "publicKey"')
label Private keys
.list-group.mb-2
.list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys')
i.fas.fa-key
.mr-auto {{path}}
button.btn.btn-link((click)='removePrivateKey(path)')
i.fas.fa-trash
button.btn.btn-secondary((click)='addPrivateKey()')
i.fas.fa-folder-open
span Add a private key
li(ngbNavItem)
a(ngbNavLink) Ports

View File

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

View File

@ -133,8 +133,6 @@ export class SSHService {
port: session.connection.port ?? 22,
sock: session.proxyCommandStream ?? session.jumpStream,
username: session.connection.user,
password: session.connection.privateKey ? undefined : '',
privateKey: session.privateKey ?? undefined,
tryKeyboard: true,
agent: 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 { SSHTabComponent } from './components/sshTab.component'
import { PasswordStorageService } from './services/passwordStorage.service'
import { SSHConnection } from './api'
import { SSHConnection, SSHSession } from './api'
/** @hidden */
@ -40,7 +40,7 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider {
{
label: 'Launch WinSCP',
click: (): void => {
this.launchWinSCP(tab.connection!)
this.launchWinSCP(tab.session!)
},
},
]
@ -60,15 +60,15 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider {
return uri
}
async launchWinSCP (connection: SSHConnection): Promise<void> {
async launchWinSCP (session: SSHSession): Promise<void> {
const path = this.getPath()
if (!path) {
return
}
const args = [await this.getURI(connection)]
if ((!connection.auth || connection.auth === 'publicKey') && connection.privateKey) {
const args = [await this.getURI(session.connection)]
if (session.activePrivateKey) {
args.push('/privatekey')
args.push(connection.privateKey)
args.push(session.activePrivateKey)
}
this.platform.exec(path, args)
}