Browse Source

ssh: remember and verify host keys - fixes #2419

Eugene Pankov 4 years ago
parent
commit
918761bbdc

+ 29 - 0
locale/app.pot

@@ -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 ""
 

+ 37 - 0
tabby-ssh/src/components/hostKeyPromptModal.component.pug

@@ -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

+ 43 - 0
tabby-ssh/src/components/hostKeyPromptModal.component.ts

@@ -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)
+    }
+}

+ 8 - 0
tabby-ssh/src/components/sshSettingsTab.component.pug

@@ -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

+ 2 - 0
tabby-ssh/src/config.ts

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

+ 3 - 0
tabby-ssh/src/index.ts

@@ -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

+ 32 - 0
tabby-ssh/src/services/sshKnownHosts.service.ts

@@ -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 })
+        }
+    }
+}

+ 54 - 11
tabby-ssh/src/session/ssh.ts

@@ -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))