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"
msgstr ""
msgid "Accept and remember key"
msgstr ""
msgid "Accept just this once"
msgstr ""
msgid "Acrylic background"
msgstr ""
@ -261,6 +267,9 @@ msgstr ""
msgid "Current color scheme"
msgstr ""
msgid "Current host key fingerprint"
msgstr ""
msgid "Current process: {name}"
msgstr ""
@ -333,6 +342,9 @@ msgstr ""
msgid "Disabled"
msgstr ""
msgid "Disconnect"
msgstr ""
msgid "Display on"
msgstr ""
@ -558,6 +570,9 @@ msgstr ""
msgid "Host key"
msgstr ""
msgid "Host key verification"
msgstr ""
msgid "Hotkeys"
msgstr ""
@ -624,6 +639,9 @@ msgstr ""
msgid "Language"
msgstr ""
msgid "Last known host key fingerprint"
msgstr ""
msgid "Launch WinSCP"
msgstr ""
@ -1368,6 +1386,9 @@ msgstr ""
msgid "Vault master passphrase needs to be set to allow storing secrets"
msgstr ""
msgid "Verify host keys when connecting"
msgstr ""
msgid "Version"
msgstr ""
@ -1392,6 +1413,9 @@ msgstr ""
msgid "Warn when closing active connections"
msgstr ""
msgid "Warning: remote host's key has suddenly changed!"
msgstr ""
msgid "We're only tracking your Tabby and OS versions."
msgstr ""
@ -1448,6 +1472,11 @@ msgstr ""
msgid "You can change it later, but it's unrecoverable if forgotten."
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"
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()',
)
.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')
.header
.title(translate) WinSCP path

View File

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

View File

@ -16,6 +16,7 @@ import { SSHTabComponent } from './components/sshTab.component'
import { SFTPPanelComponent } from './components/sftpPanel.component'
import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
import { KeyboardInteractiveAuthComponent } from './components/keyboardInteractiveAuthPanel.component'
import { HostKeyPromptModalComponent } from './components/hostKeyPromptModal.component'
import { SSHConfigProvider } from './config'
import { SSHSettingsTabProvider } from './settings'
@ -52,6 +53,7 @@ import { CommonSFTPContextMenu } from './sftpContextMenu'
SSHPortForwardingModalComponent,
SSHSettingsTabComponent,
SSHTabComponent,
HostKeyPromptModalComponent,
],
declarations: [
SSHProfileSettingsComponent,
@ -62,6 +64,7 @@ import { CommonSFTPContextMenu } from './sftpContextMenu'
SSHTabComponent,
SFTPPanelComponent,
KeyboardInteractiveAuthComponent,
HostKeyPromptModalComponent,
],
})
// 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 { Client, ClientChannel, SFTPWrapper } from 'ssh2'
import { Subject, Observable } from 'rxjs'
import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component'
import { ProxyCommandStream, SocksProxyStream } from '../services/ssh.service'
import { PasswordStorageService } from '../services/passwordStorage.service'
import { SSHKnownHostsService } from '../services/sshKnownHosts.service'
import { promisify } from 'util'
import { SFTPSession } from './sftp'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, PortForwardType, SSHProfile } from '../api'
@ -32,6 +34,11 @@ interface AuthMethod {
contents?: Buffer
}
interface Handshake {
kex: string
serverHostKey: string
}
export class KeyboardInteractivePrompt {
responses: string[] = []
@ -75,6 +82,7 @@ export class SSHSession {
private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
private willDestroy = new Subject<void>()
private keychainPasswordUsed = false
private hostKeyDigest = ''
private passwordStorage: PasswordStorageService
private ngbModal: NgbModal
@ -85,6 +93,7 @@ export class SSHSession {
private fileProviders: FileProvidersService
private config: ConfigService
private translate: TranslateService
private knownHosts: SSHKnownHostsService
constructor (
private injector: Injector,
@ -101,6 +110,7 @@ export class SSHSession {
this.fileProviders = injector.get(FileProvidersService)
this.config = injector.get(ConfigService)
this.translate = injector.get(TranslateService)
this.knownHosts = injector.get(SSHKnownHostsService)
this.willDestroy$.subscribe(() => {
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))
}
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) => {
ssh.on('ready', () => {
connected = true
@ -193,15 +215,8 @@ export class SSHSession {
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)
})
ssh.on('handshake', negotiated => {
this.logger.info('Handshake complete:', negotiated)
})
ssh.on('error', error => {
if (error.message === 'All configured authentication methods failed') {
this.passwordStorage.deletePassword(this.profile)
@ -288,12 +303,10 @@ export class SSHSession {
keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000,
keepaliveCountMax: this.profile.options.keepaliveCountMax,
readyTimeout: this.profile.options.readyTimeout,
hostVerifier: (digest: string) => {
log('Host key fingerprint:')
log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
hostVerifier: (key: any) => {
this.hostKeyDigest = crypto.createHash('sha256').update(key).digest('base64')
return true
},
hostHash: 'sha256' as any,
algorithms,
authHandler: (methodsLeft, partialSuccess, callback) => {
this.zone.run(async () => {
@ -307,6 +320,11 @@ export class SSHSession {
}
await resultPromise
await hostVerifiedPromise
for (const fw of this.profile.options.forwardedPorts ?? []) {
this.addPortForward(Object.assign(new ForwardedPort(), fw))
}
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 {
this.serviceMessage.next(msg)
this.logger.info(stripAnsi(msg))