ssh: remember and verify host keys - fixes #2419

This commit is contained in:
Eugene Pankov 2022-01-30 00:24:58 +01:00
parent ceb1b59409
commit 918761bbdc
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
8 changed files with 208 additions and 11 deletions

View File

@ -10,6 +10,12 @@ msgstr ""
msgid "Abort all" msgid "Abort all"
msgstr "" msgstr ""
msgid "Accept and remember key"
msgstr ""
msgid "Accept just this once"
msgstr ""
msgid "Acrylic background" msgid "Acrylic background"
msgstr "" msgstr ""
@ -261,6 +267,9 @@ msgstr ""
msgid "Current color scheme" msgid "Current color scheme"
msgstr "" msgstr ""
msgid "Current host key fingerprint"
msgstr ""
msgid "Current process: {name}" msgid "Current process: {name}"
msgstr "" msgstr ""
@ -333,6 +342,9 @@ msgstr ""
msgid "Disabled" msgid "Disabled"
msgstr "" msgstr ""
msgid "Disconnect"
msgstr ""
msgid "Display on" msgid "Display on"
msgstr "" msgstr ""
@ -558,6 +570,9 @@ msgstr ""
msgid "Host key" msgid "Host key"
msgstr "" msgstr ""
msgid "Host key verification"
msgstr ""
msgid "Hotkeys" msgid "Hotkeys"
msgstr "" msgstr ""
@ -624,6 +639,9 @@ msgstr ""
msgid "Language" msgid "Language"
msgstr "" msgstr ""
msgid "Last known host key fingerprint"
msgstr ""
msgid "Launch WinSCP" msgid "Launch WinSCP"
msgstr "" msgstr ""
@ -1368,6 +1386,9 @@ msgstr ""
msgid "Vault master passphrase needs to be set to allow storing secrets" msgid "Vault master passphrase needs to be set to allow storing secrets"
msgstr "" msgstr ""
msgid "Verify host keys when connecting"
msgstr ""
msgid "Version" msgid "Version"
msgstr "" msgstr ""
@ -1392,6 +1413,9 @@ msgstr ""
msgid "Warn when closing active connections" msgid "Warn when closing active connections"
msgstr "" msgstr ""
msgid "Warning: remote host's key has suddenly changed!"
msgstr ""
msgid "We're only tracking your Tabby and OS versions." msgid "We're only tracking your Tabby and OS versions."
msgstr "" msgstr ""
@ -1448,6 +1472,11 @@ msgstr ""
msgid "You can change it later, but it's unrecoverable if forgotten." msgid "You can change it later, but it's unrecoverable if forgotten."
msgstr "" msgstr ""
msgid ""
"You could be under a man-in-the-middle attack right now, or the host key "
"could have just been changed."
msgstr ""
msgid "Zoom in" msgid "Zoom in"
msgstr "" msgstr ""

View File

@ -0,0 +1,37 @@
.modal-header
h3.m-0(translate) Host key verification
.modal-body.pt-0
.alert.alert-danger(*ngIf='isMismatched')
strong(translate) Warning: remote host's key has suddenly changed!
div(translate) You could be under a man-in-the-middle attack right now, or the host key could have just been changed.
.form-group(*ngIf='isMismatched')
.d-flex.align-items-center
label(translate) Last known host key fingerprint
.badge.badge-danger.ml-auto {{ selector.type }}
code {{knownHost.digest}}
.form-group
.d-flex.align-items-center
label(translate) Current host key fingerprint
.badge.badge-success.ml-auto {{ selector.type }}
code {{digest}}
.modal-footer
.w-100
button.d-block.w-100.mb-3.btn.btn-primary(
(click)='acceptAndSave()',
[class.btn-danger]='isMismatched',
translate
) Accept and remember key
button.d-block.w-100.mb-3.btn.btn-secondary(
(click)='accept()',
[class.btn-warning]='isMismatched',
translate
) Accept just this once
button.d-block.w-100.btn.btn-secondary(
[class.btn-danger]='!isMismatched',
(click)='cancel()',
translate
) Disconnect

View File

@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Input } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { KnownHost, KnownHostSelector, SSHKnownHostsService } from '../services/sshKnownHosts.service'
/** @hidden */
@Component({
template: require('./hostKeyPromptModal.component.pug'),
})
export class HostKeyPromptModalComponent {
@Input() selector: KnownHostSelector
@Input() digest: string
knownHost: KnownHost|null
isMismatched = false
isUnknown = false
constructor (
private knownHosts: SSHKnownHostsService,
private modalInstance: NgbActiveModal,
) { }
ngOnInit () {
this.knownHost = this.knownHosts.getFor(this.selector)
if (!this.knownHost) {
this.isUnknown = true
} else if (this.knownHost.digest !== this.digest) {
this.isMismatched = true
}
}
accept () {
this.modalInstance.close(true)
}
acceptAndSave () {
this.knownHosts.store(this.selector, this.digest)
this.accept()
}
cancel () {
this.modalInstance.close(false)
}
}

View File

@ -8,6 +8,14 @@ h3 SSH
(ngModelChange)='config.save()', (ngModelChange)='config.save()',
) )
.form-line
.header
.title(translate) Verify host keys when connecting
toggle(
[(ngModel)]='config.store.ssh.verifyHostKeys',
(ngModelChange)='config.save()',
)
.form-line(*ngIf='hostApp.platform === Platform.Windows') .form-line(*ngIf='hostApp.platform === Platform.Windows')
.header .header
.title(translate) WinSCP path .title(translate) WinSCP path

View File

@ -9,6 +9,8 @@ export class SSHConfigProvider extends ConfigProvider {
agentType: 'auto', agentType: 'auto',
agentPath: null, agentPath: null,
x11Display: null, x11Display: null,
knownHosts: [],
verifyHostKeys: true,
}, },
hotkeys: { hotkeys: {
'restart-ssh-session': [], 'restart-ssh-session': [],

View File

@ -16,6 +16,7 @@ import { SSHTabComponent } from './components/sshTab.component'
import { SFTPPanelComponent } from './components/sftpPanel.component' import { SFTPPanelComponent } from './components/sftpPanel.component'
import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component' import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
import { KeyboardInteractiveAuthComponent } from './components/keyboardInteractiveAuthPanel.component' import { KeyboardInteractiveAuthComponent } from './components/keyboardInteractiveAuthPanel.component'
import { HostKeyPromptModalComponent } from './components/hostKeyPromptModal.component'
import { SSHConfigProvider } from './config' import { SSHConfigProvider } from './config'
import { SSHSettingsTabProvider } from './settings' import { SSHSettingsTabProvider } from './settings'
@ -52,6 +53,7 @@ import { CommonSFTPContextMenu } from './sftpContextMenu'
SSHPortForwardingModalComponent, SSHPortForwardingModalComponent,
SSHSettingsTabComponent, SSHSettingsTabComponent,
SSHTabComponent, SSHTabComponent,
HostKeyPromptModalComponent,
], ],
declarations: [ declarations: [
SSHProfileSettingsComponent, SSHProfileSettingsComponent,
@ -62,6 +64,7 @@ import { CommonSFTPContextMenu } from './sftpContextMenu'
SSHTabComponent, SSHTabComponent,
SFTPPanelComponent, SFTPPanelComponent,
KeyboardInteractiveAuthComponent, KeyboardInteractiveAuthComponent,
HostKeyPromptModalComponent,
], ],
}) })
// eslint-disable-next-line @typescript-eslint/no-extraneous-class // eslint-disable-next-line @typescript-eslint/no-extraneous-class

View File

@ -0,0 +1,32 @@
import { Injectable } from '@angular/core'
import { ConfigService } from 'tabby-core'
export interface KnownHostSelector {
host: string
port: number
type: string
}
export interface KnownHost extends KnownHostSelector {
digest: string
}
@Injectable({ providedIn: 'root' })
export class SSHKnownHostsService {
constructor (
private config: ConfigService,
) { }
getFor (selector: KnownHostSelector): KnownHost|null {
return this.config.store.ssh.knownHosts.find(x => x.host === selector.host && x.port === selector.port && x.type === selector.type) ?? null
}
store (selector: KnownHostSelector, digest: string): void {
const existing = this.getFor(selector)
if (existing) {
existing.digest = digest
} else {
this.config.store.ssh.knownHosts.push({ ...selector, digest })
}
}
}

View File

@ -11,8 +11,10 @@ import { ConfigService, FileProvidersService, HostAppService, NotificationsServi
import { Socket } from 'net' import { Socket } from 'net'
import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component'
import { ProxyCommandStream, SocksProxyStream } from '../services/ssh.service' import { ProxyCommandStream, SocksProxyStream } from '../services/ssh.service'
import { PasswordStorageService } from '../services/passwordStorage.service' import { PasswordStorageService } from '../services/passwordStorage.service'
import { SSHKnownHostsService } from '../services/sshKnownHosts.service'
import { promisify } from 'util' import { promisify } from 'util'
import { SFTPSession } from './sftp' import { SFTPSession } from './sftp'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, PortForwardType, SSHProfile } from '../api' import { ALGORITHM_BLACKLIST, SSHAlgorithmType, PortForwardType, SSHProfile } from '../api'
@ -32,6 +34,11 @@ interface AuthMethod {
contents?: Buffer contents?: Buffer
} }
interface Handshake {
kex: string
serverHostKey: string
}
export class KeyboardInteractivePrompt { export class KeyboardInteractivePrompt {
responses: string[] = [] responses: string[] = []
@ -75,6 +82,7 @@ export class SSHSession {
private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>() private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
private willDestroy = new Subject<void>() private willDestroy = new Subject<void>()
private keychainPasswordUsed = false private keychainPasswordUsed = false
private hostKeyDigest = ''
private passwordStorage: PasswordStorageService private passwordStorage: PasswordStorageService
private ngbModal: NgbModal private ngbModal: NgbModal
@ -85,6 +93,7 @@ export class SSHSession {
private fileProviders: FileProvidersService private fileProviders: FileProvidersService
private config: ConfigService private config: ConfigService
private translate: TranslateService private translate: TranslateService
private knownHosts: SSHKnownHostsService
constructor ( constructor (
private injector: Injector, private injector: Injector,
@ -101,6 +110,7 @@ export class SSHSession {
this.fileProviders = injector.get(FileProvidersService) this.fileProviders = injector.get(FileProvidersService)
this.config = injector.get(ConfigService) this.config = injector.get(ConfigService)
this.translate = injector.get(TranslateService) this.translate = injector.get(TranslateService)
this.knownHosts = injector.get(SSHKnownHostsService)
this.willDestroy$.subscribe(() => { this.willDestroy$.subscribe(() => {
for (const port of this.forwardedPorts) { for (const port of this.forwardedPorts) {
@ -186,6 +196,18 @@ export class SSHSession {
algorithms[key] = this.profile.options.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x)) algorithms[key] = this.profile.options.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
} }
const hostVerifiedPromise: Promise<void> = new Promise((resolve, reject) => {
ssh.on('handshake', async handshake => {
if (!await this.verifyHostKey(handshake)) {
this.ssh.end()
reject(new Error('Host key verification failed'))
return
}
this.logger.info('Handshake complete:', handshake)
resolve()
})
})
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => { const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
ssh.on('ready', () => { ssh.on('ready', () => {
connected = true connected = true
@ -193,15 +215,8 @@ export class SSHSession {
this.passwordStorage.savePassword(this.profile, this.savedPassword) this.passwordStorage.savePassword(this.profile, this.savedPassword)
} }
for (const fw of this.profile.options.forwardedPorts ?? []) {
this.addPortForward(Object.assign(new ForwardedPort(), fw))
}
this.zone.run(resolve) this.zone.run(resolve)
}) })
ssh.on('handshake', negotiated => {
this.logger.info('Handshake complete:', negotiated)
})
ssh.on('error', error => { ssh.on('error', error => {
if (error.message === 'All configured authentication methods failed') { if (error.message === 'All configured authentication methods failed') {
this.passwordStorage.deletePassword(this.profile) this.passwordStorage.deletePassword(this.profile)
@ -288,12 +303,10 @@ export class SSHSession {
keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000, keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000,
keepaliveCountMax: this.profile.options.keepaliveCountMax, keepaliveCountMax: this.profile.options.keepaliveCountMax,
readyTimeout: this.profile.options.readyTimeout, readyTimeout: this.profile.options.readyTimeout,
hostVerifier: (digest: string) => { hostVerifier: (key: any) => {
log('Host key fingerprint:') this.hostKeyDigest = crypto.createHash('sha256').update(key).digest('base64')
log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
return true return true
}, },
hostHash: 'sha256' as any,
algorithms, algorithms,
authHandler: (methodsLeft, partialSuccess, callback) => { authHandler: (methodsLeft, partialSuccess, callback) => {
this.zone.run(async () => { this.zone.run(async () => {
@ -307,6 +320,11 @@ export class SSHSession {
} }
await resultPromise await resultPromise
await hostVerifiedPromise
for (const fw of this.profile.options.forwardedPorts ?? []) {
this.addPortForward(Object.assign(new ForwardedPort(), fw))
}
this.open = true this.open = true
@ -371,6 +389,31 @@ export class SSHSession {
}) })
} }
private async verifyHostKey (handshake: Handshake): Promise<boolean> {
this.emitServiceMessage('Host key fingerprint:')
this.emitServiceMessage(colors.white.bgBlack(` ${handshake.serverHostKey} `) + colors.bgBlackBright(' ' + this.hostKeyDigest + ' '))
if (!this.config.store.ssh.verifyHostKeys) {
return true
}
const selector = {
host: this.profile.options.host,
port: this.profile.options.port ?? 22,
type: handshake.serverHostKey,
}
const knownHost = this.knownHosts.getFor(selector)
if (!knownHost || knownHost.digest !== this.hostKeyDigest) {
const modal = this.ngbModal.open(HostKeyPromptModalComponent)
modal.componentInstance.selector = selector
modal.componentInstance.digest = this.hostKeyDigest
try {
return await modal.result
} catch {
return false
}
}
return true
}
emitServiceMessage (msg: string): void { emitServiceMessage (msg: string): void {
this.serviceMessage.next(msg) this.serviceMessage.next(msg)
this.logger.info(stripAnsi(msg)) this.logger.info(stripAnsi(msg))