mirror of
https://github.com/Eugeny/tabby.git
synced 2024-11-27 06:00:26 +08:00
ssh: remember and verify host keys - fixes #2419
This commit is contained in:
parent
ceb1b59409
commit
918761bbdc
@ -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 ""
|
||||||
|
|
||||||
|
37
tabby-ssh/src/components/hostKeyPromptModal.component.pug
Normal file
37
tabby-ssh/src/components/hostKeyPromptModal.component.pug
Normal 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
|
43
tabby-ssh/src/components/hostKeyPromptModal.component.ts
Normal file
43
tabby-ssh/src/components/hostKeyPromptModal.component.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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': [],
|
||||||
|
@ -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
|
||||||
|
32
tabby-ssh/src/services/sshKnownHosts.service.ts
Normal file
32
tabby-ssh/src/services/sshKnownHosts.service.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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))
|
||||||
|
Loading…
Reference in New Issue
Block a user