diff --git a/terminus-ssh/src/components/sshTab.component.ts b/terminus-ssh/src/components/sshTab.component.ts index cada3375..0619da29 100644 --- a/terminus-ssh/src/components/sshTab.component.ts +++ b/terminus-ssh/src/components/sshTab.component.ts @@ -25,6 +25,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent { private homeEndSubscription: Subscription private recentInputs = '' private reconnectOffered = false + private spinner = new Spinner({ + text: 'Connecting', + stream: { + write: x => this.write(x), + }, + }) + private spinnerActive = false constructor ( injector: Injector, @@ -113,32 +120,22 @@ export class SSHTabComponent extends BaseTerminalTabComponent { 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`) - const spinner = new Spinner({ - text: 'Connecting', - stream: { - write: x => this.write(x), - }, - }) - spinner.setSpinnerString(6) - spinner.start() + this.startSpinner() + + this.attachSessionHandler(session.serviceMessage$.subscribe(msg => { + this.pauseSpinner(() => { + this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) + session.resize(this.size.columns, this.size.rows) + }) + })) try { - await this.ssh.connectSession(session, (message: string) => { - spinner.stop(true) - this.write(message + '\r\n') - spinner.start() - }) - spinner.stop(true) + await this.ssh.connectSession(session) + this.stopSpinner() } catch (e) { - spinner.stop(true) + this.stopSpinner() this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') return } @@ -232,4 +229,24 @@ export class SSHTabComponent extends BaseTerminalTabComponent { this.homeEndSubscription.unsubscribe() 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() + } + } } diff --git a/terminus-ssh/src/services/passwordStorage.service.ts b/terminus-ssh/src/services/passwordStorage.service.ts index 053588ce..8d02e8c6 100644 --- a/terminus-ssh/src/services/passwordStorage.service.ts +++ b/terminus-ssh/src/services/passwordStorage.service.ts @@ -5,26 +5,44 @@ import * as keytar from 'keytar' @Injectable({ providedIn: 'root' }) export class PasswordStorageService { async savePassword (connection: SSHConnection, password: string): Promise { - let key = `ssh@${connection.host}` - if (connection.port) { - key = `ssh@${connection.host}:${connection.port}` - } + const key = this.getKeyForConnection(connection) return keytar.setPassword(key, connection.user, password) } async deletePassword (connection: SSHConnection): Promise { - let key = `ssh@${connection.host}` - if (connection.port) { - key = `ssh@${connection.host}:${connection.port}` - } + const key = this.getKeyForConnection(connection) await keytar.deletePassword(key, connection.user) } async loadPassword (connection: SSHConnection): Promise { + const key = this.getKeyForConnection(connection) + return keytar.getPassword(key, connection.user) + } + + async savePrivateKeyPassword (id: string, password: string): Promise { + const key = this.getKeyForPrivateKey(id) + return keytar.setPassword(key, 'user', password) + } + + async deletePrivateKeyPassword (id: string): Promise { + const key = this.getKeyForPrivateKey(id) + await keytar.deletePassword(key, 'user') + } + + async loadPrivateKeyPassword (id: string): Promise { + const key = this.getKeyForPrivateKey(id) + return keytar.getPassword(key, 'user') + } + + private getKeyForConnection (connection: SSHConnection): string { let key = `ssh@${connection.host}` if (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}` } } diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index b771b6a8..5ddd3e5b 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/terminus-ssh/src/services/ssh.service.ts @@ -1,6 +1,6 @@ import colors from 'ansi-colors' import { Duplex } from 'stream' -import stripAnsi from 'strip-ansi' +import * as crypto from 'crypto' import { open as openTemp } from 'temp' import { Injectable, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -55,51 +55,29 @@ export class SSHService { return session } - async loadPrivateKeyForSession (session: SSHSession, logCallback?: SSHLogCallback): Promise { + async loadPrivateKeyForSession (session: SSHSession): Promise { let privateKey: string|null = null let privateKeyPath = session.connection.privateKey if (!privateKeyPath) { const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa') if (await fs.exists(userKeyPath)) { - logCallback?.('Using user\'s default private key') + session.emitServiceMessage('Using user\'s default private key') privateKeyPath = userKeyPath } } if (privateKeyPath) { - logCallback?.('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) + session.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' ')) try { privateKey = (await fs.readFile(privateKeyPath)).toString() } 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') } if (privateKey) { - let parsedKey: any = null - 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 parsedKey = await this.parsePrivateKey(privateKey) const sshFormatKey = parsedKey.toString('openssh') const temp = await openTemp() @@ -134,15 +112,40 @@ export class SSHService { return privateKey } - async connectSession (session: SSHSession, logCallback?: SSHLogCallback): Promise { - if (!logCallback) { - logCallback = () => null - } + async parsePrivateKey (privateKey: string): Promise { + const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex') + 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) => { - logCallback!(s) - this.logger.info(stripAnsi(s)) + const modal = this.ngbModal.open(PromptModalComponent) + modal.componentInstance.prompt = 'Private key passphrase' + 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 { + const log = (s: any) => session.emitServiceMessage(s) let privateKey: string|null = null @@ -154,7 +157,8 @@ export class SSHService { for (const key of Object.keys(session.connection.algorithms ?? {})) { algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x)) } - await new Promise(async (resolve, reject) => { + + const resultPromise: Promise = new Promise(async (resolve, reject) => { ssh.on('ready', () => { connected = true if (savedPassword) { @@ -211,137 +215,143 @@ export class SSHService { 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(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(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 {