This commit is contained in:
Eugene 2024-07-09 21:34:01 +02:00
parent bba72b4bb8
commit a01d693eec
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
12 changed files with 453 additions and 430 deletions

View File

@ -76,7 +76,6 @@
"source-code-pro": "^2.38.0", "source-code-pro": "^2.38.0",
"source-map-loader": "^4.0.1", "source-map-loader": "^4.0.1",
"source-sans-pro": "3.6.0", "source-sans-pro": "3.6.0",
"ssh2": "^1.14.0",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"svg-inline-loader": "^0.8.2", "svg-inline-loader": "^0.8.2",
"thenby": "^1.3.4", "thenby": "^1.3.4",

View File

@ -1,39 +0,0 @@
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
index 9860e3f..ee82e51 100644
--- a/node_modules/ssh2/lib/protocol/keyParser.js
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
@@ -15,6 +15,7 @@ const {
sign: sign_,
verify: verify_,
} = require('crypto');
+const { createVerify: createVerifyDSS } = require('browserify-sign')
const supportedOpenSSLCiphers = getCiphers();
const { Ber } = require('asn1');
@@ -404,6 +405,17 @@ const BaseKey = {
return new Error('No public key available');
if (!algo || typeof algo !== 'string')
algo = this[SYM_HASH_ALGO];
+
+ if (algo === 'dss1') {
+ const verifier = createVerifyDSS('DSA-SHA1');
+ verifier.update(data);
+ try {
+ return verifier.verify(pem, signature);
+ } catch (ex) {
+ return ex;
+ }
+ }
+
try {
return verify_(algo, data, pem, signature);
} catch (ex) {
@@ -1343,7 +1355,7 @@ function parseDER(data, baseType, comment, fullType) {
return new Error('Malformed OpenSSH public key');
pubPEM = genOpenSSLDSAPub(p, q, g, y);
pubSSH = genOpenSSHDSAPub(p, q, g, y);
- algo = 'sha1';
+ algo = 'dss1';
break;
}
case 'ssh-ed25519': {

View File

@ -23,7 +23,6 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "20.3.1", "@types/node": "20.3.1",
"@types/ssh2": "^0.5.46",
"ansi-colors": "^4.1.1", "ansi-colors": "^4.1.1",
"diffie-hellman": "^5.0.3", "diffie-hellman": "^5.0.3",
"sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136", "sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136",

View File

@ -1,20 +1,44 @@
import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import * as russh from 'russh'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api' import { SSHAlgorithmType } from './api'
// Counteracts https://github.com/mscdex/ssh2/commit/f1b5ac3c81734c194740016eab79a699efae83d8 export const supportedAlgorithms = {
ALGORITHMS.DEFAULT_CIPHER.push('aes128-gcm') [SSHAlgorithmType.KEX]: russh.getSupportedKexAlgorithms().filter(x => x !== 'none'),
ALGORITHMS.DEFAULT_CIPHER.push('aes256-gcm') [SSHAlgorithmType.HOSTKEY]: russh.getSupportedKeyTypes().filter(x => x !== 'none'),
ALGORITHMS.SUPPORTED_CIPHER.push('aes128-gcm') [SSHAlgorithmType.CIPHER]: russh.getSupportedCiphers().filter(x => x !== 'clear'),
ALGORITHMS.SUPPORTED_CIPHER.push('aes256-gcm') [SSHAlgorithmType.HMAC]: russh.getSupportedMACs().filter(x => x !== 'none'),
}
export const supportedAlgorithms: Record<string, string> = {}
export const defaultAlgorithms = {
for (const k of Object.values(SSHAlgorithmType)) { [SSHAlgorithmType.KEX]: [
const supportedAlg = { 'curve25519-sha256',
[SSHAlgorithmType.KEX]: 'SUPPORTED_KEX', 'curve25519-sha256@libssh.org',
[SSHAlgorithmType.HOSTKEY]: 'SUPPORTED_SERVER_HOST_KEY', 'diffie-hellman-group16-sha512',
[SSHAlgorithmType.CIPHER]: 'SUPPORTED_CIPHER', 'diffie-hellman-group14-sha256',
[SSHAlgorithmType.HMAC]: 'SUPPORTED_MAC', 'ext-info-c',
}[k] 'ext-info-s',
supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort() 'kex-strict-c-v00@openssh.com',
'kex-strict-s-v00@openssh.com',
],
[SSHAlgorithmType.HOSTKEY]: [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp521',
'rsa-sha2-256',
'rsa-sha2-512',
],
[SSHAlgorithmType.CIPHER]: [
'chacha20-poly1305@openssh.com',
'aes256-gcm@openssh.com',
'aes256-ctr',
'aes192-ctr',
'aes128-ctr',
],
[SSHAlgorithmType.HMAC]: [
'hmac-sha2-512-etm@openssh.com',
'hmac-sha2-256-etm@openssh.com',
'hmac-sha2-512',
'hmac-sha2-256',
'hmac-sha1-etm@openssh.com',
'hmac-sha1',
],
} }

View File

@ -51,13 +51,3 @@ export interface ForwardedPortConfig {
targetPort: number targetPort: number
description: string description: string
} }
export let ALGORITHM_BLACKLIST = [
// cause native crashes in node crypto, use EC instead
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
]
if (!process.env.TABBY_ENABLE_SSH_ALG_BLACKLIST) {
ALGORITHM_BLACKLIST = []
}

View File

@ -94,17 +94,17 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
} }
}) })
session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( // session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
'127.0.0.1', 0, profile.options.host, profile.options.port ?? 22, // '127.0.0.1', 0, profile.options.host, profile.options.port ?? 22,
(err, stream) => { // (err, stream) => {
if (err) { // if (err) {
jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) // jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
reject(err) // reject(err)
return // return
} // }
resolve(stream) // resolve(stream)
}, // },
)) // ))
} }
} }

View File

@ -1,11 +1,11 @@
import { Injectable, InjectFlags, Injector } from '@angular/core' import { Injectable, InjectFlags, Injector } from '@angular/core'
import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core' import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core'
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
import { SSHTabComponent } from './components/sshTab.component' import { SSHTabComponent } from './components/sshTab.component'
import { PasswordStorageService } from './services/passwordStorage.service' import { PasswordStorageService } from './services/passwordStorage.service'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api' import { SSHAlgorithmType, SSHProfile } from './api'
import { SSHProfileImporter } from './api/importer' import { SSHProfileImporter } from './api/importer'
import { defaultAlgorithms } from './algorithms'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile> { export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile> {
@ -29,10 +29,10 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
agentForward: false, agentForward: false,
warnOnClose: null, warnOnClose: null,
algorithms: { algorithms: {
hmac: [], hmac: [] as string[],
kex: [], kex: [] as string[],
cipher: [], cipher: [] as string[],
serverHostKey: [], serverHostKey: [] as string[],
}, },
proxyCommand: null, proxyCommand: null,
forwardedPorts: [], forwardedPorts: [],
@ -54,13 +54,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
) { ) {
super() super()
for (const k of Object.values(SSHAlgorithmType)) { for (const k of Object.values(SSHAlgorithmType)) {
const defaultAlg = { this.configDefaults.options.algorithms[k] = [...defaultAlgorithms[k]]
[SSHAlgorithmType.KEX]: 'DEFAULT_KEX',
[SSHAlgorithmType.HOSTKEY]: 'DEFAULT_SERVER_HOST_KEY',
[SSHAlgorithmType.CIPHER]: 'DEFAULT_CIPHER',
[SSHAlgorithmType.HMAC]: 'DEFAULT_MAC',
}[k]
this.configDefaults.options.algorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x))
this.configDefaults.options.algorithms[k].sort() this.configDefaults.options.algorithms[k].sort()
} }
} }

View File

@ -1,6 +1,6 @@
import * as shellQuote from 'shell-quote' import * as shellQuote from 'shell-quote'
import * as net from 'net' import * as net from 'net'
import * as fs from 'fs/promises' // import * as fs from 'fs/promises'
import * as tmp from 'tmp-promise' import * as tmp from 'tmp-promise'
import socksv5 from '@luminati-io/socksv5' import socksv5 from '@luminati-io/socksv5'
import { Duplex } from 'stream' import { Duplex } from 'stream'
@ -55,7 +55,7 @@ export class SSHService {
let tmpFile: tmp.FileResult|null = null let tmpFile: tmp.FileResult|null = null
if (session.activePrivateKey) { if (session.activePrivateKey) {
tmpFile = await tmp.file() tmpFile = await tmp.file()
await fs.writeFile(tmpFile.path, session.activePrivateKey) // await fs.writeFile(tmpFile.path, session.activePrivateKey)
const winSCPcom = path.slice(0, -3) + 'com' const winSCPcom = path.slice(0, -3) + 'com'
await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, `/output=${tmpFile.path}`]) await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, `/output=${tmpFile.path}`])
args.push(`/privatekey=${tmpFile.path}`) args.push(`/privatekey=${tmpFile.path}`)

View File

@ -1,15 +1,15 @@
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { ClientChannel } from 'ssh2'
import { Injector } from '@angular/core' import { Injector } from '@angular/core'
import { LogService } from 'tabby-core' import { LogService } from 'tabby-core'
import { BaseSession, UTF8SplitterMiddleware, InputProcessor } from 'tabby-terminal' import { BaseSession, UTF8SplitterMiddleware, InputProcessor } from 'tabby-terminal'
import { SSHSession } from './ssh' import { SSHSession } from './ssh'
import { SSHProfile } from '../api' import { SSHProfile } from '../api'
import * as russh from 'russh'
export class SSHShellSession extends BaseSession { export class SSHShellSession extends BaseSession {
shell?: ClientChannel shell?: russh.Channel
get serviceMessage$ (): Observable<string> { return this.serviceMessage } get serviceMessage$ (): Observable<string> { return this.serviceMessage }
private serviceMessage = new Subject<string>() private serviceMessage = new Subject<string>()
private ssh: SSHSession|null private ssh: SSHSession|null
@ -53,19 +53,19 @@ export class SSHShellSession extends BaseSession {
this.loginScriptProcessor?.executeUnconditionalScripts() this.loginScriptProcessor?.executeUnconditionalScripts()
this.shell.on('greeting', greeting => { // this.shell.on('greeting', greeting => {
this.emitServiceMessage(`Shell greeting: ${greeting}`) // this.emitServiceMessage(`Shell greeting: ${greeting}`)
// })
// this.shell.on('banner', banner => {
// this.emitServiceMessage(`Shell banner: ${banner}`)
// })
this.shell.data$.subscribe(data => {
this.emitOutput(Buffer.from(data))
}) })
this.shell.on('banner', banner => { this.shell.eof$.subscribe(() => {
this.emitServiceMessage(`Shell banner: ${banner}`)
})
this.shell.on('data', data => {
this.emitOutput(data)
})
this.shell.on('end', () => {
this.logger.info('Shell session ended') this.logger.info('Shell session ended')
if (this.open) { if (this.open) {
this.destroy() this.destroy()
@ -79,19 +79,22 @@ export class SSHShellSession extends BaseSession {
} }
resize (columns: number, rows: number): void { resize (columns: number, rows: number): void {
if (this.shell) { this.shell?.resizePTY({
this.shell.setWindow(rows, columns, rows, columns) columns,
} rows,
pixHeight: 0,
pixWidth: 0,
})
} }
write (data: Buffer): void { write (data: Buffer): void {
if (this.shell) { if (this.shell) {
this.shell.write(data) this.shell.write(new Uint8Array(data))
} }
} }
kill (signal?: string): void { kill (_signal?: string): void {
this.shell?.signal(signal ?? 'TERM') // this.shell?.signal(signal ?? 'TERM')
} }
async destroy (): Promise<void> { async destroy (): Promise<void> {

View File

@ -3,22 +3,22 @@ import * as crypto from 'crypto'
import * as sshpk from 'sshpk' import * as sshpk from 'sshpk'
import colors from 'ansi-colors' import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { Injector, NgZone } from '@angular/core' import { Injector } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService, Logger, TranslateService } from 'tabby-core' import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, PromptModalComponent, LogService, Logger, TranslateService } from 'tabby-core'
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 { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component'
import { HTTPProxyStream, ProxyCommandStream, SocksProxyStream } from '../services/ssh.service' // import { HTTPProxyStream, 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 { SSHKnownHostsService } from '../services/sshKnownHosts.service'
import { promisify } from 'util'
import { SFTPSession } from './sftp' import { SFTPSession } from './sftp'
import { SSHAlgorithmType, PortForwardType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator } from '../api' import { SSHAlgorithmType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator } from '../api'
import { ForwardedPort } from './forwards' import { ForwardedPort } from './forwards'
import { X11Socket } from './x11' // import { X11Socket } from './x11'
import { supportedAlgorithms } from '../algorithms' import { supportedAlgorithms } from '../algorithms'
import * as russh from 'russh'
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
@ -33,32 +33,42 @@ interface AuthMethod {
contents?: Buffer contents?: Buffer
} }
interface Handshake { // interface Handshake {
kex: string // kex: string
serverHostKey: string // serverHostKey: string
} // }
export class KeyboardInteractivePrompt { export class KeyboardInteractivePrompt {
responses: string[] = [] readonly responses: string[] = []
private _resolve: (value: string[]) => void
private _reject: (reason: any) => void
readonly promise = new Promise<string[]>((resolve, reject) => {
this._resolve = resolve
this._reject = reject
})
constructor ( constructor (
public name: string, public name: string,
public instruction: string, public instruction: string,
public prompts: Prompt[], public prompts: Prompt[],
private callback: (_: string[]) => void,
) { ) {
this.responses = new Array(this.prompts.length).fill('') this.responses = new Array(this.prompts.length).fill('')
} }
respond (): void { respond (): void {
this.callback(this.responses) this._resolve(this.responses)
}
reject (): void {
this._reject(new Error('Keyboard-interactive auth rejected'))
} }
} }
export class SSHSession { export class SSHSession {
shell?: ClientChannel shell?: russh.Channel
ssh: Client ssh: russh.SSHClient|russh.AuthenticatedSSHClient
sftp?: SFTPWrapper // sftp?: SFTPWrapper
forwardedPorts: ForwardedPort[] = [] forwardedPorts: ForwardedPort[] = []
jumpStream: any jumpStream: any
proxyCommandStream: SSHProxyStream|null = null proxyCommandStream: SSHProxyStream|null = null
@ -68,7 +78,7 @@ export class SSHSession {
get willDestroy$ (): Observable<void> { return this.willDestroy } get willDestroy$ (): Observable<void> { return this.willDestroy }
agentPath?: string agentPath?: string
activePrivateKey: string|null = null activePrivateKey: russh.KeyPair|null = null
authUsername: string|null = null authUsername: string|null = null
open = false open = false
@ -80,14 +90,13 @@ 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
private hostApp: HostAppService private hostApp: HostAppService
private platform: PlatformService private platform: PlatformService
private notifications: NotificationsService private notifications: NotificationsService
private zone: NgZone // private zone: NgZone
private fileProviders: FileProvidersService private fileProviders: FileProvidersService
private config: ConfigService private config: ConfigService
private translate: TranslateService private translate: TranslateService
@ -95,7 +104,7 @@ export class SSHSession {
private privateKeyImporters: AutoPrivateKeyLocator[] private privateKeyImporters: AutoPrivateKeyLocator[]
constructor ( constructor (
private injector: Injector, injector: Injector,
public profile: SSHProfile, public profile: SSHProfile,
) { ) {
this.logger = injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`) this.logger = injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`)
@ -105,7 +114,7 @@ export class SSHSession {
this.hostApp = injector.get(HostAppService) this.hostApp = injector.get(HostAppService)
this.platform = injector.get(PlatformService) this.platform = injector.get(PlatformService)
this.notifications = injector.get(NotificationsService) this.notifications = injector.get(NotificationsService)
this.zone = injector.get(NgZone) // this.zone = injector.get(NgZone)
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)
@ -184,248 +193,245 @@ export class SSHSession {
} }
async openSFTP (): Promise<SFTPSession> { async openSFTP (): Promise<SFTPSession> {
if (!this.sftp) { throw new Error('Not implemented')
this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))()) // if (!this.sftp) {
} // this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))())
return new SFTPSession(this.sftp, this.injector) // }
// return new SFTPSession(this.sftp, this.injector)
} }
async start (): Promise<void> { async start (): Promise<void> {
const log = (s: any) => this.emitServiceMessage(s) // const log = (s: any) => this.emitServiceMessage(s)
const ssh = new Client()
this.ssh = ssh
await this.init() await this.init()
let connected = false
const algorithms = {} const algorithms = {}
for (const key of Object.values(SSHAlgorithmType)) { for (const key of Object.values(SSHAlgorithmType)) {
algorithms[key] = this.profile.options.algorithms![key].filter(x => supportedAlgorithms[key].includes(x)) algorithms[key] = this.profile.options.algorithms![key].filter(x => supportedAlgorithms[key].includes(x))
} }
const hostVerifiedPromise: Promise<void> = new Promise((resolve, reject) => { // todo migrate connection opts
ssh.on('handshake', async handshake => { this.ssh = await russh.SSHClient.connect(
if (!await this.verifyHostKey(handshake)) { `${this.profile.options.host.trim()}:${this.profile.options.port ?? 22}`,
this.ssh.end() async key => {
reject(new Error('Host key verification failed')) if (!await this.verifyHostKey(key)) {
return false
} }
this.logger.info('Handshake complete:', handshake) this.logger.info('Host key verified')
resolve() return true
}) },
{
preferred: {
ciphers: this.profile.options.algorithms?.[SSHAlgorithmType.CIPHER]?.filter(x => supportedAlgorithms[SSHAlgorithmType.CIPHER].includes(x)),
kex: this.profile.options.algorithms?.[SSHAlgorithmType.KEX]?.filter(x => supportedAlgorithms[SSHAlgorithmType.KEX].includes(x)),
mac: this.profile.options.algorithms?.[SSHAlgorithmType.HMAC]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HMAC].includes(x)),
key: this.profile.options.algorithms?.[SSHAlgorithmType.HOSTKEY]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HOSTKEY].includes(x)),
},
},
)
this.ssh.disconnect$.subscribe(() => {
if (this.open) {
this.destroy()
}
}) })
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => { // auth
ssh.on('ready', () => {
connected = true
if (this.savedPassword) {
this.passwordStorage.savePassword(this.profile, this.savedPassword)
}
this.zone.run(resolve) this.authUsername ??= this.profile.options.user
}) if (!this.authUsername) {
ssh.on('error', error => { const modal = this.ngbModal.open(PromptModalComponent)
if (error.message === 'All configured authentication methods failed') { modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
this.passwordStorage.deletePassword(this.profile) try {
} const result = await modal.result.catch(() => null)
this.zone.run(() => { this.authUsername = result?.value ?? null
if (connected) { } catch {
// eslint-disable-next-line @typescript-eslint/no-base-to-string this.authUsername = 'root'
this.notifications.error(error.toString()) }
} else { }
reject(error)
}
})
})
ssh.on('close', () => {
if (this.open) {
this.destroy()
}
})
ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => { if (this.authUsername?.startsWith('$')) {
this.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt( try {
name, const result = process.env[this.authUsername.slice(1)]
instructions, this.authUsername = result ?? this.authUsername
prompts, } catch {
finish, this.authUsername = 'root'
)) }
})) }
ssh.on('greeting', greeting => { const authenticatedClient = await this.handleAuth()
if (!this.profile.options.skipBanner) { if (authenticatedClient) {
log('Greeting: ' + greeting) this.ssh = authenticatedClient
} } else {
}) this.ssh.disconnect()
this.passwordStorage.deletePassword(this.profile)
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new Error('Authentication rejected')
}
ssh.on('banner', banner => { // auth success
if (!this.profile.options.skipBanner) {
log(banner) if (this.savedPassword) {
} this.passwordStorage.savePassword(this.profile, this.savedPassword)
}) }
})
//zone ?
// const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
// ssh.on('greeting', greeting => {
// if (!this.profile.options.skipBanner) {
// log('Greeting: ' + greeting)
// }
// })
// ssh.on('banner', banner => {
// if (!this.profile.options.skipBanner) {
// log(banner)
// }
// })
// })
try { try {
if (this.profile.options.socksProxyHost) { // if (this.profile.options.socksProxyHost) {
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) // this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`)
this.proxyCommandStream = new SocksProxyStream(this.profile) // this.proxyCommandStream = new SocksProxyStream(this.profile)
} // }
if (this.profile.options.httpProxyHost) { // if (this.profile.options.httpProxyHost) {
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`) // this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`)
this.proxyCommandStream = new HTTPProxyStream(this.profile) // this.proxyCommandStream = new HTTPProxyStream(this.profile)
} // }
if (this.profile.options.proxyCommand) { // if (this.profile.options.proxyCommand) {
this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`) // this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`)
this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand) // this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand)
} // }
if (this.proxyCommandStream) { // if (this.proxyCommandStream) {
this.proxyCommandStream.destroyed$.subscribe(err => { // this.proxyCommandStream.destroyed$.subscribe(err => {
if (err) { // if (err) {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
this.destroy() // this.destroy()
} // }
}) // })
this.proxyCommandStream.message$.subscribe(message => { // this.proxyCommandStream.message$.subscribe(message => {
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim()) // this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim())
}) // })
await this.proxyCommandStream.start() // await this.proxyCommandStream.start()
} // }
this.authUsername ??= this.profile.options.user
if (!this.authUsername) {
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
try {
const result = await modal.result.catch(() => null)
this.authUsername = result?.value ?? null
} catch {
this.authUsername = 'root'
}
}
if (this.authUsername?.startsWith('$')) {
try {
const result = process.env[this.authUsername.slice(1)]
this.authUsername = result ?? this.authUsername
} catch {
this.authUsername = 'root'
}
}
ssh.connect({ // ssh.connect({
host: this.profile.options.host.trim(), // host: this.profile.options.host.trim(),
port: this.profile.options.port ?? 22, // port: this.profile.options.port ?? 22,
sock: this.proxyCommandStream?.socket ?? this.jumpStream, // sock: this.proxyCommandStream?.socket ?? this.jumpStream,
username: this.authUsername ?? undefined, // username: this.authUsername ?? undefined,
tryKeyboard: true, // tryKeyboard: true,
agent: this.agentPath, // agent: this.agentPath,
agentForward: this.profile.options.agentForward && !!this.agentPath, // agentForward: this.profile.options.agentForward && !!this.agentPath,
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: (key: any) => { // algorithms,
this.hostKeyDigest = crypto.createHash('sha256').update(key).digest('base64') // authHandler: (methodsLeft, partialSuccess, callback) => {
return true // this.zone.run(async () => {
}, // callback(await this.handleAuth(methodsLeft))
algorithms, // })
authHandler: (methodsLeft, partialSuccess, callback) => { // },
this.zone.run(async () => { // })
callback(await this.handleAuth(methodsLeft))
})
},
})
} catch (e) { } catch (e) {
this.notifications.error(e.message) this.notifications.error(e.message)
throw e throw e
} }
await resultPromise // for (const fw of this.profile.options.forwardedPorts ?? []) {
await hostVerifiedPromise // this.addPortForward(Object.assign(new ForwardedPort(), fw))
// }
for (const fw of this.profile.options.forwardedPorts ?? []) {
this.addPortForward(Object.assign(new ForwardedPort(), fw))
}
this.open = true this.open = true
this.ssh.on('tcp connection', (details, accept, reject) => { // this.ssh.on('tcp connection', (details, accept, reject) => {
this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`) // this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`)
const forward = this.forwardedPorts.find(x => x.port === details.destPort) // const forward = this.forwardedPorts.find(x => x.port === details.destPort)
if (!forward) { // if (!forward) {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`)
reject() // reject()
return // return
} // }
const socket = new Socket() // const socket = new Socket()
socket.connect(forward.targetPort, forward.targetAddress) // socket.connect(forward.targetPort, forward.targetAddress)
socket.on('error', e => { // socket.on('error', e => {
// eslint-disable-next-line @typescript-eslint/no-base-to-string // // eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`)
reject() // reject()
}) // })
socket.on('connect', () => { // socket.on('connect', () => {
this.logger.info('Connection forwarded') // this.logger.info('Connection forwarded')
const stream = accept() // const stream = accept()
stream.pipe(socket) // stream.pipe(socket)
socket.pipe(stream) // socket.pipe(stream)
stream.on('close', () => { // stream.on('close', () => {
socket.destroy() // socket.destroy()
}) // })
socket.on('close', () => { // socket.on('close', () => {
stream.close() // stream.close()
}) // })
}) // })
}) // })
this.ssh.on('x11', async (details, accept, reject) => { // this.ssh.on('x11', async (details, accept, reject) => {
this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`) // this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`)
const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0' // const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0'
this.logger.debug(`Trying display ${displaySpec}`) // this.logger.debug(`Trying display ${displaySpec}`)
const socket = new X11Socket() // const socket = new X11Socket()
try { // try {
const x11Stream = await socket.connect(displaySpec) // const x11Stream = await socket.connect(displaySpec)
this.logger.info('Connection forwarded') // this.logger.info('Connection forwarded')
const stream = accept() // const stream = accept()
stream.pipe(x11Stream) // stream.pipe(x11Stream)
x11Stream.pipe(stream) // x11Stream.pipe(stream)
stream.on('close', () => { // stream.on('close', () => {
socket.destroy() // socket.destroy()
}) // })
x11Stream.on('close', () => { // x11Stream.on('close', () => {
stream.close() // stream.close()
}) // })
} catch (e) { // } catch (e) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string // // eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`)
this.emitServiceMessage(` Tabby tried to connect to ${JSON.stringify(X11Socket.resolveDisplaySpec(displaySpec))} based on the DISPLAY environment var (${displaySpec})`) // this.emitServiceMessage(` Tabby tried to connect to ${JSON.stringify(X11Socket.resolveDisplaySpec(displaySpec))} based on the DISPLAY environment var (${displaySpec})`)
if (process.platform === 'win32') { // if (process.platform === 'win32') {
this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:') // this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:')
this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/') // this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/')
this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/') // this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/')
} // }
reject() // reject()
} // }
}) // })
} }
private async verifyHostKey (handshake: Handshake): Promise<boolean> { private async verifyHostKey (key: russh.SshPublicKey): Promise<boolean> {
this.emitServiceMessage('Host key fingerprint:') this.emitServiceMessage('Host key fingerprint:')
this.emitServiceMessage(colors.white.bgBlack(` ${handshake.serverHostKey} `) + colors.bgBlackBright(' ' + this.hostKeyDigest + ' ')) this.emitServiceMessage(colors.white.bgBlack(` ${key.algorithm()} `) + colors.bgBlackBright(' ' + key.fingerprint() + ' '))
if (!this.config.store.ssh.verifyHostKeys) { if (!this.config.store.ssh.verifyHostKeys) {
return true return true
} }
const selector = { const selector = {
host: this.profile.options.host, host: this.profile.options.host,
port: this.profile.options.port ?? 22, port: this.profile.options.port ?? 22,
type: handshake.serverHostKey, type: key.algorithm(),
} }
const keyDigest = crypto.createHash('sha256').update(key.bytes()).digest('base64')
const knownHost = this.knownHosts.getFor(selector) const knownHost = this.knownHosts.getFor(selector)
if (!knownHost || knownHost.digest !== this.hostKeyDigest) { if (!knownHost || knownHost.digest !== keyDigest) {
const modal = this.ngbModal.open(HostKeyPromptModalComponent) const modal = this.ngbModal.open(HostKeyPromptModalComponent)
modal.componentInstance.selector = selector modal.componentInstance.selector = selector
modal.componentInstance.digest = this.hostKeyDigest modal.componentInstance.digest = keyDigest
return modal.result.catch(() => false) return modal.result.catch(() => false)
} }
return true return true
@ -447,13 +453,21 @@ export class SSHSession {
this.keyboardInteractivePrompt.next(prompt) this.keyboardInteractivePrompt.next(prompt)
} }
async handleAuth (methodsLeft?: string[] | null): Promise<any> { async handleAuth (methodsLeft?: string[] | null): Promise<russh.AuthenticatedSSHClient|null> {
this.activePrivateKey = null this.activePrivateKey = null
if (!(this.ssh instanceof russh.SSHClient)) {
throw new Error('Wrong state for auth handling')
}
if (!this.authUsername) {
throw new Error('No username')
}
while (true) { while (true) {
const method = this.remainingAuthMethods.shift() const method = this.remainingAuthMethods.shift()
if (!method) { if (!method) {
return false return null
} }
if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') { if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') {
// Agent can still be used even if not in methodsLeft // Agent can still be used even if not in methodsLeft
@ -463,10 +477,9 @@ export class SSHSession {
if (method.type === 'password') { if (method.type === 'password') {
if (this.profile.options.password) { if (this.profile.options.password) {
this.emitServiceMessage(this.translate.instant('Using preset password')) this.emitServiceMessage(this.translate.instant('Using preset password'))
return { const result = await this.ssh.authenticateWithPassword(this.authUsername, this.profile.options.password)
type: 'password', if (result) {
username: this.authUsername, return result
password: this.profile.options.password,
} }
} }
@ -475,10 +488,9 @@ export class SSHSession {
if (password) { if (password) {
this.emitServiceMessage(this.translate.instant('Trying saved password')) this.emitServiceMessage(this.translate.instant('Trying saved password'))
this.keychainPasswordUsed = true this.keychainPasswordUsed = true
return { const result = await this.ssh.authenticateWithPassword(this.authUsername, password)
type: 'password', if (result) {
username: this.authUsername, return result
password,
} }
} }
} }
@ -489,15 +501,14 @@ export class SSHSession {
modal.componentInstance.showRememberCheckbox = true modal.componentInstance.showRememberCheckbox = true
try { try {
const result = await modal.result.catch(() => null) const promptResult = await modal.result.catch(() => null)
if (result) { if (promptResult) {
if (result.remember) { if (promptResult.remember) {
this.savedPassword = result.value this.savedPassword = promptResult.value
} }
return { const result = await this.ssh.authenticateWithPassword(this.authUsername, promptResult.value)
type: 'password', if (result) {
username: this.authUsername, return result
password: result.value,
} }
} else { } else {
continue continue
@ -509,81 +520,116 @@ export class SSHSession {
if (method.type === 'publickey' && method.contents) { if (method.type === 'publickey' && method.contents) {
try { try {
const key = await this.loadPrivateKey(method.name!, method.contents) const key = await this.loadPrivateKey(method.name!, method.contents)
return { const result = await this.ssh.authenticateWithKeyPair(this.authUsername, key)
type: 'publickey', if (result) {
username: this.authUsername, return result
key,
} }
} catch (e) { } catch (e) {
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`) this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`)
continue continue
} }
} }
return method.type if (method.type === 'keyboard-interactive') {
let state: russh.AuthenticatedSSHClient|russh.KeyboardInteractiveAuthenticationState = await this.ssh.startKeyboardInteractiveAuthentication(this.authUsername)
while (true) {
if (state.state === 'failure') {
break
}
const prompts = await state.prompts()
let responses: string[] = []
// OpenSSH can send a k-i request without prompts
// just respond ok to it
if (prompts.length > 0) {
const prompt = new KeyboardInteractivePrompt(
state.name,
state.instructions,
await state.prompts(),
)
this.emitKeyboardInteractivePrompt(prompt)
try {
// eslint-disable-next-line @typescript-eslint/await-thenable
responses = await prompt.promise
} catch {
break // this loop
}
}
state = await this.ssh .continueKeyboardInteractiveAuthentication(responses)
if (state instanceof russh.AuthenticatedSSHClient) {
return state
}
}
}
} }
return null
} }
async addPortForward (fw: ForwardedPort): Promise<void> { async addPortForward (_fw: ForwardedPort): Promise<void> {
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { // if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => { // await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => {
this.logger.info(`New connection on ${fw}`) // this.logger.info(`New connection on ${fw}`)
this.ssh.forwardOut( // this.ssh.forwardOut(
sourceAddress ?? '127.0.0.1', // sourceAddress ?? '127.0.0.1',
sourcePort ?? 0, // sourcePort ?? 0,
targetAddress, // targetAddress,
targetPort, // targetPort,
(err, stream) => { // (err, stream) => {
if (err) { // if (err) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string // // eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`)
reject() // reject()
return // return
} // }
const socket = accept() // const socket = accept()
stream.pipe(socket) // stream.pipe(socket)
socket.pipe(stream) // socket.pipe(stream)
stream.on('close', () => { // stream.on('close', () => {
socket.destroy() // socket.destroy()
}) // })
socket.on('close', () => { // socket.on('close', () => {
stream.close() // stream.close()
}) // })
}, // },
) // )
}).then(() => { // }).then(() => {
this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`) // this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`)
this.forwardedPorts.push(fw) // this.forwardedPorts.push(fw)
}).catch(e => { // }).catch(e => {
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`)
throw e // throw e
}) // })
} // }
if (fw.type === PortForwardType.Remote) { // if (fw.type === PortForwardType.Remote) {
await new Promise<void>((resolve, reject) => { // await new Promise<void>((resolve, reject) => {
this.ssh.forwardIn(fw.host, fw.port, err => { // this.ssh.forwardIn(fw.host, fw.port, err => {
if (err) { // if (err) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string // // eslint-disable-next-line @typescript-eslint/no-base-to-string
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`) // this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`)
reject(err) // reject(err)
return // return
} // }
resolve() // resolve()
}) // })
}) // })
this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`) // this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`)
this.forwardedPorts.push(fw) // this.forwardedPorts.push(fw)
} // }
} }
async removePortForward (fw: ForwardedPort): Promise<void> { async removePortForward (fw: ForwardedPort): Promise<void> {
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { // if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
fw.stopLocalListener() // fw.stopLocalListener()
this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) // this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
} // }
if (fw.type === PortForwardType.Remote) { // if (fw.type === PortForwardType.Remote) {
this.ssh.unforwardIn(fw.host, fw.port) // this.ssh.unforwardIn(fw.host, fw.port)
this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) // this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
} // }
this.emitServiceMessage(`Stopped forwarding ${fw}`) this.emitServiceMessage(`Stopped forwarding ${fw}`)
} }
@ -593,25 +639,36 @@ export class SSHSession {
this.willDestroy.complete() this.willDestroy.complete()
this.serviceMessage.complete() this.serviceMessage.complete()
this.proxyCommandStream?.stop() this.proxyCommandStream?.stop()
this.ssh.end() this.ssh.disconnect()
} }
openShellChannel (options: { x11: boolean }): Promise<ClientChannel> { async openShellChannel (options: { x11: boolean }): Promise<russh.Channel> {
return new Promise<ClientChannel>((resolve, reject) => { if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => { throw new Error('Cannot open shell channel before auth')
if (err) { }
reject(err) const ch = await this.ssh.openSessionChannel()
} else { await ch.requestPTY('xterm-256color', {
resolve(shell) columns: 80,
} rows: 24,
}) pixHeight: 0,
pixWidth: 0,
}) })
if (options.x11) {
await ch.requestX11Forwarding({
singleConnection: false,
authProtocol: 'MIT-MAGIC-COOKIE-1',
authCookie: crypto.randomBytes(16).toString('hex'),
screenNumber: 0,
})
}
await ch.requestShell()
return ch
} }
async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise<string|null> { async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise<russh.KeyPair> {
this.emitServiceMessage(`Loading private key: ${name}`) this.emitServiceMessage(`Loading private key: ${name}`)
const parsedKey = await this.parsePrivateKey(privateKeyContents.toString()) //todo passphrase handling
this.activePrivateKey = parsedKey.toString('openssh') this.activePrivateKey = await russh.KeyPair.parse(privateKeyContents.toString())
return this.activePrivateKey return this.activePrivateKey
} }

View File

@ -7,9 +7,4 @@ import config from '../webpack.plugin.config.mjs'
export default () => config({ export default () => config({
name: 'ssh', name: 'ssh',
dirname: __dirname, dirname: __dirname,
alias: {
'cpu-features': false,
'./crypto/build/Release/sshcrypto.node': false,
'../build/Release/cpufeatures.node': false,
},
}) })

View File

@ -157,6 +157,7 @@ export default options => {
'os', 'os',
'path', 'path',
'readline', 'readline',
'russh',
'@luminati-io/socksv5', '@luminati-io/socksv5',
'stream', 'stream',
'windows-native-registry', 'windows-native-registry',