mirror of
https://github.com/Eugeny/tabby.git
synced 2025-06-24 21:40:06 +00:00
an option to remember private key passphrases - fixes #3689
This commit is contained in:
parent
220ae6ccaa
commit
f87efcf5bd
@ -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),
|
||||
},
|
||||
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)
|
||||
})
|
||||
spinner.setSpinnerString(6)
|
||||
spinner.start()
|
||||
}))
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,26 +5,44 @@ import * as keytar from 'keytar'
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PasswordStorageService {
|
||||
async savePassword (connection: SSHConnection, password: string): Promise<void> {
|
||||
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<void> {
|
||||
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<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}`
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
@ -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<string|null> {
|
||||
async loadPrivateKeyForSession (session: SSHSession): Promise<string|null> {
|
||||
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<void> {
|
||||
if (!logCallback) {
|
||||
logCallback = () => null
|
||||
async parsePrivateKey (privateKey: string): Promise<any> {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const log = (s: any) => {
|
||||
logCallback!(s)
|
||||
this.logger.info(stripAnsi(s))
|
||||
}
|
||||
async connectSession (session: SSHSession): Promise<void> {
|
||||
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<void> = new Promise(async (resolve, reject) => {
|
||||
ssh.on('ready', () => {
|
||||
connected = true
|
||||
if (savedPassword) {
|
||||
@ -211,6 +215,7 @@ export class SSHService {
|
||||
log(banner)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let agent: string|null = null
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
@ -233,16 +238,20 @@ export class SSHService {
|
||||
|
||||
const authMethodsLeft = ['none']
|
||||
if (!session.connection.auth || session.connection.auth === 'publicKey') {
|
||||
privateKey = await this.loadPrivateKeyForSession(session, log)
|
||||
try {
|
||||
privateKey = await this.loadPrivateKeyForSession(session)
|
||||
} catch (e) {
|
||||
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`)
|
||||
}
|
||||
if (!privateKey) {
|
||||
log('\r\nPrivate key auth selected, but no key is loaded\r\n')
|
||||
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) {
|
||||
log('\r\nAgent auth selected, but no running agent is detected\r\n')
|
||||
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
|
||||
} else {
|
||||
authMethodsLeft.push('agent')
|
||||
}
|
||||
@ -257,11 +266,11 @@ export class SSHService {
|
||||
|
||||
try {
|
||||
if (session.connection.proxyCommand) {
|
||||
log(`Using proxy command: ${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)
|
||||
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim())
|
||||
})
|
||||
|
||||
await session.proxyCommandStream.start()
|
||||
@ -281,8 +290,8 @@ export class SSHService {
|
||||
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 + ' '))
|
||||
log('Host key fingerprint:')
|
||||
log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
|
||||
return true
|
||||
},
|
||||
hostHash: 'sha256' as any,
|
||||
@ -304,7 +313,7 @@ export class SSHService {
|
||||
} as any)
|
||||
} catch (e) {
|
||||
this.notifications.error(e.message)
|
||||
return reject(e)
|
||||
throw e
|
||||
}
|
||||
|
||||
let keychainPasswordUsed = false
|
||||
@ -337,11 +346,12 @@ export class SSHService {
|
||||
return result.value
|
||||
}
|
||||
return ''
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return resultPromise
|
||||
}
|
||||
|
||||
async showConnectionSelector (): Promise<void> {
|
||||
|
Loading…
x
Reference in New Issue
Block a user