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
10 changed files with 131 additions and 86 deletions

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