an option to remember private key passphrases - fixes #3689

This commit is contained in:
Eugene Pankov 2021-05-02 13:11:15 +02:00
parent 220ae6ccaa
commit f87efcf5bd
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
3 changed files with 241 additions and 196 deletions

View File

@ -25,6 +25,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
private homeEndSubscription: Subscription private homeEndSubscription: Subscription
private recentInputs = '' private recentInputs = ''
private reconnectOffered = false private reconnectOffered = false
private spinner = new Spinner({
text: 'Connecting',
stream: {
write: x => this.write(x),
},
})
private spinnerActive = false
constructor ( constructor (
injector: Injector, injector: Injector,
@ -113,32 +120,22 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
this.sessionStack.push(session) this.sessionStack.push(session)
} }
this.attachSessionHandler(session.serviceMessage$.subscribe(msg => {
this.write(`\r\n${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
session.resize(this.size.columns, this.size.rows)
}))
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.connection.host}\r\n`) this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
const spinner = new Spinner({ this.startSpinner()
text: 'Connecting',
stream: { this.attachSessionHandler(session.serviceMessage$.subscribe(msg => {
write: x => this.write(x), this.pauseSpinner(() => {
}, this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
}) session.resize(this.size.columns, this.size.rows)
spinner.setSpinnerString(6) })
spinner.start() }))
try { try {
await this.ssh.connectSession(session, (message: string) => { await this.ssh.connectSession(session)
spinner.stop(true) this.stopSpinner()
this.write(message + '\r\n')
spinner.start()
})
spinner.stop(true)
} catch (e) { } catch (e) {
spinner.stop(true) this.stopSpinner()
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
return return
} }
@ -232,4 +229,24 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
this.homeEndSubscription.unsubscribe() this.homeEndSubscription.unsubscribe()
super.ngOnDestroy() super.ngOnDestroy()
} }
private startSpinner () {
this.spinner.setSpinnerString(6)
this.spinner.start()
this.spinnerActive = true
}
private stopSpinner () {
this.spinner.stop(true)
this.spinnerActive = false
}
private pauseSpinner (work: () => void) {
const wasActive = this.spinnerActive
this.stopSpinner()
work()
if (wasActive) {
this.startSpinner()
}
}
} }

View File

@ -5,26 +5,44 @@ import * as keytar from 'keytar'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PasswordStorageService { export class PasswordStorageService {
async savePassword (connection: SSHConnection, password: string): Promise<void> { async savePassword (connection: SSHConnection, password: string): Promise<void> {
let key = `ssh@${connection.host}` const key = this.getKeyForConnection(connection)
if (connection.port) {
key = `ssh@${connection.host}:${connection.port}`
}
return keytar.setPassword(key, connection.user, password) return keytar.setPassword(key, connection.user, password)
} }
async deletePassword (connection: SSHConnection): Promise<void> { async deletePassword (connection: SSHConnection): Promise<void> {
let key = `ssh@${connection.host}` const key = this.getKeyForConnection(connection)
if (connection.port) {
key = `ssh@${connection.host}:${connection.port}`
}
await keytar.deletePassword(key, connection.user) await keytar.deletePassword(key, connection.user)
} }
async loadPassword (connection: SSHConnection): Promise<string|null> { async loadPassword (connection: SSHConnection): Promise<string|null> {
const key = this.getKeyForConnection(connection)
return keytar.getPassword(key, connection.user)
}
async savePrivateKeyPassword (id: string, password: string): Promise<void> {
const key = this.getKeyForPrivateKey(id)
return keytar.setPassword(key, 'user', password)
}
async deletePrivateKeyPassword (id: string): Promise<void> {
const key = this.getKeyForPrivateKey(id)
await keytar.deletePassword(key, 'user')
}
async loadPrivateKeyPassword (id: string): Promise<string|null> {
const key = this.getKeyForPrivateKey(id)
return keytar.getPassword(key, 'user')
}
private getKeyForConnection (connection: SSHConnection): string {
let key = `ssh@${connection.host}` let key = `ssh@${connection.host}`
if (connection.port) { if (connection.port) {
key = `ssh@${connection.host}:${connection.port}` key = `ssh@${connection.host}:${connection.port}`
} }
return keytar.getPassword(key, connection.user) return key
}
private getKeyForPrivateKey (id: string): string {
return `ssh-private-key:${id}`
} }
} }

View File

@ -1,6 +1,6 @@
import colors from 'ansi-colors' import colors from 'ansi-colors'
import { Duplex } from 'stream' import { Duplex } from 'stream'
import stripAnsi from 'strip-ansi' import * as crypto from 'crypto'
import { open as openTemp } from 'temp' import { open as openTemp } from 'temp'
import { Injectable, NgZone } from '@angular/core' import { Injectable, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@ -55,51 +55,29 @@ export class SSHService {
return session return session
} }
async loadPrivateKeyForSession (session: SSHSession, logCallback?: SSHLogCallback): Promise<string|null> { async loadPrivateKeyForSession (session: SSHSession): Promise<string|null> {
let privateKey: string|null = null let privateKey: string|null = null
let privateKeyPath = session.connection.privateKey let privateKeyPath = session.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)) {
logCallback?.('Using user\'s default private key') session.emitServiceMessage('Using user\'s default private key')
privateKeyPath = userKeyPath privateKeyPath = userKeyPath
} }
} }
if (privateKeyPath) { if (privateKeyPath) {
logCallback?.('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) session.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
try { try {
privateKey = (await fs.readFile(privateKeyPath)).toString() privateKey = (await fs.readFile(privateKeyPath)).toString()
} catch (error) { } catch (error) {
logCallback?.(colors.bgRed.black(' X ') + 'Could not read the private key file') session.emitServiceMessage(colors.bgRed.black(' X ') + 'Could not read the private key file')
this.notifications.error('Could not read the private key file') this.notifications.error('Could not read the private key file')
} }
if (privateKey) { if (privateKey) {
let parsedKey: any = null const parsedKey = await this.parsePrivateKey(privateKey)
try {
parsedKey = sshpk.parsePrivateKey(privateKey, 'auto')
} catch (e) {
if (e instanceof sshpk.KeyEncryptedError) {
const modal = this.ngbModal.open(PromptModalComponent)
logCallback?.(colors.bgYellow.yellow.black(' ! ') + ' Key requires passphrase')
modal.componentInstance.prompt = 'Private key passphrase'
modal.componentInstance.password = true
let passphrase = ''
try {
const result = await modal.result
passphrase = result?.value
} catch { }
parsedKey = sshpk.parsePrivateKey(
privateKey,
'auto',
{ passphrase: passphrase }
)
} else {
throw e
}
}
const sshFormatKey = parsedKey.toString('openssh') const sshFormatKey = parsedKey.toString('openssh')
const temp = await openTemp() const temp = await openTemp()
@ -134,15 +112,40 @@ export class SSHService {
return privateKey return privateKey
} }
async connectSession (session: SSHSession, logCallback?: SSHLogCallback): Promise<void> { async parsePrivateKey (privateKey: string): Promise<any> {
if (!logCallback) { const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex')
logCallback = () => null let passphrase: string|null = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
} while (true) {
try {
return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase })
} catch (e) {
this.notifications.error('Could not read the private key', e.toString())
if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) {
await this.passwordStorage.deletePrivateKeyPassword(keyHash)
const log = (s: any) => { const modal = this.ngbModal.open(PromptModalComponent)
logCallback!(s) modal.componentInstance.prompt = 'Private key passphrase'
this.logger.info(stripAnsi(s)) modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
try {
const result = await modal.result
passphrase = result?.value
if (passphrase && result.remember) {
this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase)
}
} catch {
throw e
}
} else {
throw e
}
}
} }
}
async connectSession (session: SSHSession): Promise<void> {
const log = (s: any) => session.emitServiceMessage(s)
let privateKey: string|null = null let privateKey: string|null = null
@ -154,7 +157,8 @@ export class SSHService {
for (const key of Object.keys(session.connection.algorithms ?? {})) { for (const key of Object.keys(session.connection.algorithms ?? {})) {
algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x)) algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
} }
await new Promise(async (resolve, reject) => {
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
ssh.on('ready', () => { ssh.on('ready', () => {
connected = true connected = true
if (savedPassword) { if (savedPassword) {
@ -211,137 +215,143 @@ export class SSHService {
log(banner) log(banner)
} }
}) })
let agent: string|null = null
if (this.hostApp.platform === Platform.Windows) {
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
agent = WINDOWS_OPENSSH_AGENT_PIPE
} else {
// eslint-disable-next-line @typescript-eslint/no-shadow
const pageantRunning = await new Promise<boolean>(resolve => {
windowsProcessTreeNative.getProcessList(list => { // eslint-disable-line block-scoped-var
resolve(list.some(x => x.name === 'pageant.exe'))
}, 0)
})
if (pageantRunning) {
agent = 'pageant'
}
}
} else {
agent = process.env.SSH_AUTH_SOCK!
}
const authMethodsLeft = ['none']
if (!session.connection.auth || session.connection.auth === 'publicKey') {
privateKey = await this.loadPrivateKeyForSession(session, log)
if (!privateKey) {
log('\r\nPrivate key auth selected, but no key is loaded\r\n')
} else {
authMethodsLeft.push('publickey')
}
}
if (!session.connection.auth || session.connection.auth === 'agent') {
if (!agent) {
log('\r\nAgent auth selected, but no running agent is detected\r\n')
} else {
authMethodsLeft.push('agent')
}
}
if (!session.connection.auth || session.connection.auth === 'password') {
authMethodsLeft.push('password')
}
if (!session.connection.auth || session.connection.auth === 'keyboardInteractive') {
authMethodsLeft.push('keyboard-interactive')
}
authMethodsLeft.push('hostbased')
try {
if (session.connection.proxyCommand) {
log(`Using proxy command: ${session.connection.proxyCommand}`)
session.proxyCommandStream = new ProxyCommandStream(session.connection.proxyCommand)
session.proxyCommandStream.output$.subscribe((message: string) => {
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message)
})
await session.proxyCommandStream.start()
}
ssh.connect({
host: session.connection.host,
port: session.connection.port ?? 22,
sock: session.proxyCommandStream ?? session.jumpStream,
username: session.connection.user,
password: session.connection.privateKey ? undefined : '',
privateKey: privateKey ?? undefined,
tryKeyboard: true,
agent: agent ?? undefined,
agentForward: session.connection.agentForward && !!agent,
keepaliveInterval: session.connection.keepaliveInterval ?? 15000,
keepaliveCountMax: session.connection.keepaliveCountMax,
readyTimeout: session.connection.readyTimeout,
hostVerifier: (digest: string) => {
log(colors.bgWhite(' ') + ' Host key fingerprint:')
log(colors.bgWhite(' ') + ' ' + colors.black.bgWhite(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
return true
},
hostHash: 'sha256' as any,
algorithms,
authHandler: methodsLeft => {
while (true) {
const method = authMethodsLeft.shift()
if (!method) {
return false
}
if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') {
// Agent can still be used even if not in methodsLeft
this.logger.info('Server does not support auth method', method)
continue
}
return method
}
},
} as any)
} catch (e) {
this.notifications.error(e.message)
return reject(e)
}
let keychainPasswordUsed = false
;(ssh as any).config.password = () => this.zone.run(async () => {
if (session.connection.password) {
log('Using preset password')
return session.connection.password
}
if (!keychainPasswordUsed) {
const password = await this.passwordStorage.loadPassword(session.connection)
if (password) {
log('Trying saved password')
keychainPasswordUsed = true
return password
}
}
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Password for ${session.connection.user}@${session.connection.host}`
modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
try {
const result = await modal.result
if (result) {
if (result.remember) {
savedPassword = result.value
}
return result.value
}
return ''
} catch (_) {
return ''
}
})
}) })
let agent: string|null = null
if (this.hostApp.platform === Platform.Windows) {
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
agent = WINDOWS_OPENSSH_AGENT_PIPE
} else {
// eslint-disable-next-line @typescript-eslint/no-shadow
const pageantRunning = await new Promise<boolean>(resolve => {
windowsProcessTreeNative.getProcessList(list => { // eslint-disable-line block-scoped-var
resolve(list.some(x => x.name === 'pageant.exe'))
}, 0)
})
if (pageantRunning) {
agent = 'pageant'
}
}
} else {
agent = process.env.SSH_AUTH_SOCK!
}
const authMethodsLeft = ['none']
if (!session.connection.auth || session.connection.auth === 'publicKey') {
try {
privateKey = await this.loadPrivateKeyForSession(session)
} catch (e) {
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`)
}
if (!privateKey) {
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`)
} else {
authMethodsLeft.push('publickey')
}
}
if (!session.connection.auth || session.connection.auth === 'agent') {
if (!agent) {
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
} else {
authMethodsLeft.push('agent')
}
}
if (!session.connection.auth || session.connection.auth === 'password') {
authMethodsLeft.push('password')
}
if (!session.connection.auth || session.connection.auth === 'keyboardInteractive') {
authMethodsLeft.push('keyboard-interactive')
}
authMethodsLeft.push('hostbased')
try {
if (session.connection.proxyCommand) {
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.connection.proxyCommand}`)
session.proxyCommandStream = new ProxyCommandStream(session.connection.proxyCommand)
session.proxyCommandStream.output$.subscribe((message: string) => {
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim())
})
await session.proxyCommandStream.start()
}
ssh.connect({
host: session.connection.host,
port: session.connection.port ?? 22,
sock: session.proxyCommandStream ?? session.jumpStream,
username: session.connection.user,
password: session.connection.privateKey ? undefined : '',
privateKey: privateKey ?? undefined,
tryKeyboard: true,
agent: agent ?? undefined,
agentForward: session.connection.agentForward && !!agent,
keepaliveInterval: session.connection.keepaliveInterval ?? 15000,
keepaliveCountMax: session.connection.keepaliveCountMax,
readyTimeout: session.connection.readyTimeout,
hostVerifier: (digest: string) => {
log('Host key fingerprint:')
log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
return true
},
hostHash: 'sha256' as any,
algorithms,
authHandler: methodsLeft => {
while (true) {
const method = authMethodsLeft.shift()
if (!method) {
return false
}
if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') {
// Agent can still be used even if not in methodsLeft
this.logger.info('Server does not support auth method', method)
continue
}
return method
}
},
} as any)
} catch (e) {
this.notifications.error(e.message)
throw e
}
let keychainPasswordUsed = false
;(ssh as any).config.password = () => this.zone.run(async () => {
if (session.connection.password) {
log('Using preset password')
return session.connection.password
}
if (!keychainPasswordUsed) {
const password = await this.passwordStorage.loadPassword(session.connection)
if (password) {
log('Trying saved password')
keychainPasswordUsed = true
return password
}
}
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Password for ${session.connection.user}@${session.connection.host}`
modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
try {
const result = await modal.result
if (result) {
if (result.remember) {
savedPassword = result.value
}
return result.value
}
return ''
} catch {
return ''
}
})
return resultPromise
} }
async showConnectionSelector (): Promise<void> { async showConnectionSelector (): Promise<void> {