diff --git a/locale/app.pot b/locale/app.pot index 0c421e29..a9c1decf 100644 --- a/locale/app.pot +++ b/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 "" diff --git a/tabby-ssh/src/components/hostKeyPromptModal.component.pug b/tabby-ssh/src/components/hostKeyPromptModal.component.pug new file mode 100644 index 00000000..b71bb1fd --- /dev/null +++ b/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 diff --git a/tabby-ssh/src/components/hostKeyPromptModal.component.ts b/tabby-ssh/src/components/hostKeyPromptModal.component.ts new file mode 100644 index 00000000..80586086 --- /dev/null +++ b/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) + } +} diff --git a/tabby-ssh/src/components/sshSettingsTab.component.pug b/tabby-ssh/src/components/sshSettingsTab.component.pug index b1bf73c8..fd50f44f 100644 --- a/tabby-ssh/src/components/sshSettingsTab.component.pug +++ b/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 diff --git a/tabby-ssh/src/config.ts b/tabby-ssh/src/config.ts index f8fb3fa3..8c32ba89 100644 --- a/tabby-ssh/src/config.ts +++ b/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': [], diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index a38c516b..750838e5 100644 --- a/tabby-ssh/src/index.ts +++ b/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 diff --git a/tabby-ssh/src/services/sshKnownHosts.service.ts b/tabby-ssh/src/services/sshKnownHosts.service.ts new file mode 100644 index 00000000..c56b41cb --- /dev/null +++ b/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 }) + } + } +} diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 0effb95c..b9cbe050 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/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() private willDestroy = new Subject() 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 = 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 = 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 { + 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))