ssh.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import * as fs from 'mz/fs'
  2. import * as crypto from 'crypto'
  3. import * as path from 'path'
  4. // eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
  5. import * as sshpk from 'sshpk'
  6. import colors from 'ansi-colors'
  7. import stripAnsi from 'strip-ansi'
  8. import { Injector, NgZone } from '@angular/core'
  9. import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
  10. import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService } from 'tabby-core'
  11. import { BaseSession } from 'tabby-terminal'
  12. import { Socket } from 'net'
  13. import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
  14. import { Subject, Observable } from 'rxjs'
  15. import { ProxyCommandStream } from '../services/ssh.service'
  16. import { PasswordStorageService } from '../services/passwordStorage.service'
  17. import { promisify } from 'util'
  18. import { SFTPSession } from './sftp'
  19. import { ALGORITHM_BLACKLIST, SSHAlgorithmType, PortForwardType, SSHProfile } from '../api'
  20. import { ForwardedPort } from './forwards'
  21. import { X11Socket } from './x11'
  22. const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
  23. interface AuthMethod {
  24. type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
  25. name?: string
  26. contents?: Buffer
  27. }
  28. export class KeyboardInteractivePrompt {
  29. responses: string[] = []
  30. constructor (
  31. public name: string,
  32. public instruction: string,
  33. public prompts: string[],
  34. private callback: (_: string[]) => void,
  35. ) { }
  36. respond (): void {
  37. this.callback(this.responses)
  38. }
  39. }
  40. export class SSHSession extends BaseSession {
  41. shell?: ClientChannel
  42. ssh: Client
  43. sftp?: SFTPWrapper
  44. forwardedPorts: ForwardedPort[] = []
  45. jumpStream: any
  46. proxyCommandStream: ProxyCommandStream|null = null
  47. savedPassword?: string
  48. get serviceMessage$ (): Observable<string> { return this.serviceMessage }
  49. get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt }
  50. agentPath?: string
  51. activePrivateKey: string|null = null
  52. authUsername: string|null = null
  53. private remainingAuthMethods: AuthMethod[] = []
  54. private serviceMessage = new Subject<string>()
  55. private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
  56. private keychainPasswordUsed = false
  57. private passwordStorage: PasswordStorageService
  58. private ngbModal: NgbModal
  59. private hostApp: HostAppService
  60. private platform: PlatformService
  61. private notifications: NotificationsService
  62. private zone: NgZone
  63. private fileProviders: FileProvidersService
  64. private config: ConfigService
  65. constructor (
  66. private injector: Injector,
  67. public profile: SSHProfile,
  68. ) {
  69. super(injector.get(LogService).create(`ssh-${profile.options.host}-${profile.options.port}`))
  70. this.passwordStorage = injector.get(PasswordStorageService)
  71. this.ngbModal = injector.get(NgbModal)
  72. this.hostApp = injector.get(HostAppService)
  73. this.platform = injector.get(PlatformService)
  74. this.notifications = injector.get(NotificationsService)
  75. this.zone = injector.get(NgZone)
  76. this.fileProviders = injector.get(FileProvidersService)
  77. this.config = injector.get(ConfigService)
  78. this.destroyed$.subscribe(() => {
  79. for (const port of this.forwardedPorts) {
  80. port.stopLocalListener()
  81. }
  82. })
  83. this.setLoginScriptsOptions(profile.options)
  84. }
  85. async init (): Promise<void> {
  86. if (this.hostApp.platform === Platform.Windows) {
  87. if (this.config.store.ssh.agentType === 'auto') {
  88. if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
  89. this.agentPath = WINDOWS_OPENSSH_AGENT_PIPE
  90. } else {
  91. if (await this.platform.isProcessRunning('pageant.exe')) {
  92. this.agentPath = 'pageant'
  93. }
  94. }
  95. } else if (this.config.store.ssh.agentType === 'pageant') {
  96. this.agentPath = 'pageant'
  97. } else {
  98. this.agentPath = this.config.store.ssh.agentPath || WINDOWS_OPENSSH_AGENT_PIPE
  99. }
  100. } else {
  101. this.agentPath = process.env.SSH_AUTH_SOCK!
  102. }
  103. this.remainingAuthMethods = [{ type: 'none' }]
  104. if (!this.profile.options.auth || this.profile.options.auth === 'publicKey') {
  105. if (this.profile.options.privateKeys?.length) {
  106. for (const pk of this.profile.options.privateKeys) {
  107. try {
  108. this.remainingAuthMethods.push({
  109. type: 'publickey',
  110. name: pk,
  111. contents: await this.fileProviders.retrieveFile(pk),
  112. })
  113. } catch (error) {
  114. this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Could not load private key ${pk}: ${error}`)
  115. }
  116. }
  117. } else {
  118. this.remainingAuthMethods.push({
  119. type: 'publickey',
  120. name: 'auto',
  121. })
  122. }
  123. }
  124. if (!this.profile.options.auth || this.profile.options.auth === 'agent') {
  125. if (!this.agentPath) {
  126. this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
  127. } else {
  128. this.remainingAuthMethods.push({ type: 'agent' })
  129. }
  130. }
  131. if (!this.profile.options.auth || this.profile.options.auth === 'password') {
  132. this.remainingAuthMethods.push({ type: 'password' })
  133. }
  134. if (!this.profile.options.auth || this.profile.options.auth === 'keyboardInteractive') {
  135. this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
  136. }
  137. this.remainingAuthMethods.push({ type: 'hostbased' })
  138. }
  139. async openSFTP (): Promise<SFTPSession> {
  140. if (!this.sftp) {
  141. this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))())
  142. }
  143. return new SFTPSession(this.sftp, this.injector)
  144. }
  145. async start (interactive = true): Promise<void> {
  146. const log = (s: any) => this.emitServiceMessage(s)
  147. const ssh = new Client()
  148. this.ssh = ssh
  149. await this.init()
  150. let connected = false
  151. const algorithms = {}
  152. for (const key of Object.values(SSHAlgorithmType)) {
  153. algorithms[key] = this.profile.options.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
  154. }
  155. const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
  156. ssh.on('ready', () => {
  157. connected = true
  158. if (this.savedPassword) {
  159. this.passwordStorage.savePassword(this.profile, this.savedPassword)
  160. }
  161. for (const fw of this.profile.options.forwardedPorts ?? []) {
  162. this.addPortForward(Object.assign(new ForwardedPort(), fw))
  163. }
  164. this.zone.run(resolve)
  165. })
  166. ssh.on('handshake', negotiated => {
  167. this.logger.info('Handshake complete:', negotiated)
  168. })
  169. ssh.on('error', error => {
  170. if (error.message === 'All configured authentication methods failed') {
  171. this.passwordStorage.deletePassword(this.profile)
  172. }
  173. this.zone.run(() => {
  174. if (connected) {
  175. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  176. this.notifications.error(error.toString())
  177. } else {
  178. reject(error)
  179. }
  180. })
  181. })
  182. ssh.on('close', () => {
  183. if (this.open) {
  184. this.destroy()
  185. }
  186. })
  187. ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => {
  188. this.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt(
  189. name,
  190. instructions,
  191. prompts.map(x => x.prompt),
  192. finish,
  193. ))
  194. }))
  195. ssh.on('greeting', greeting => {
  196. if (!this.profile.options.skipBanner) {
  197. log('Greeting: ' + greeting)
  198. }
  199. })
  200. ssh.on('banner', banner => {
  201. if (!this.profile.options.skipBanner) {
  202. log(banner)
  203. }
  204. })
  205. })
  206. try {
  207. if (this.profile.options.proxyCommand) {
  208. this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`)
  209. this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand)
  210. this.proxyCommandStream.on('error', err => {
  211. this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
  212. this.destroy()
  213. })
  214. this.proxyCommandStream.output$.subscribe((message: string) => {
  215. this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim())
  216. })
  217. await this.proxyCommandStream.start()
  218. }
  219. this.authUsername ??= this.profile.options.user
  220. if (!this.authUsername) {
  221. const modal = this.ngbModal.open(PromptModalComponent)
  222. modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
  223. try {
  224. const result = await modal.result
  225. this.authUsername = result?.value ?? null
  226. } catch {
  227. this.authUsername = 'root'
  228. }
  229. }
  230. ssh.connect({
  231. host: this.profile.options.host.trim(),
  232. port: this.profile.options.port ?? 22,
  233. sock: this.proxyCommandStream ?? this.jumpStream,
  234. username: this.authUsername ?? undefined,
  235. tryKeyboard: true,
  236. agent: this.agentPath,
  237. agentForward: this.profile.options.agentForward && !!this.agentPath,
  238. keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000,
  239. keepaliveCountMax: this.profile.options.keepaliveCountMax,
  240. readyTimeout: this.profile.options.readyTimeout,
  241. hostVerifier: (digest: string) => {
  242. log('Host key fingerprint:')
  243. log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
  244. return true
  245. },
  246. hostHash: 'sha256' as any,
  247. algorithms,
  248. authHandler: (methodsLeft, partialSuccess, callback) => {
  249. this.zone.run(async () => {
  250. const a = await this.handleAuth(methodsLeft)
  251. console.warn(a)
  252. callback(a)
  253. })
  254. },
  255. })
  256. } catch (e) {
  257. this.notifications.error(e.message)
  258. throw e
  259. }
  260. await resultPromise
  261. this.open = true
  262. if (!interactive) {
  263. return
  264. }
  265. // -----------
  266. try {
  267. this.shell = await this.openShellChannel({ x11: this.profile.options.x11 })
  268. } catch (err) {
  269. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected opening a shell channel: ${err}`)
  270. if (err.toString().includes('Unable to request X11')) {
  271. this.emitServiceMessage(' Make sure `xauth` is installed on the remote side')
  272. }
  273. return
  274. }
  275. this.loginScriptProcessor?.executeUnconditionalScripts()
  276. this.shell.on('greeting', greeting => {
  277. this.emitServiceMessage(`Shell greeting: ${greeting}`)
  278. })
  279. this.shell.on('banner', banner => {
  280. this.emitServiceMessage(`Shell banner: ${banner}`)
  281. })
  282. this.shell.on('data', data => {
  283. this.emitOutput(data)
  284. })
  285. this.shell.on('end', () => {
  286. this.logger.info('Shell session ended')
  287. if (this.open) {
  288. this.destroy()
  289. }
  290. })
  291. this.ssh.on('tcp connection', (details, accept, reject) => {
  292. this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`)
  293. const forward = this.forwardedPorts.find(x => x.port === details.destPort)
  294. if (!forward) {
  295. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`)
  296. reject()
  297. return
  298. }
  299. const socket = new Socket()
  300. socket.connect(forward.targetPort, forward.targetAddress)
  301. socket.on('error', e => {
  302. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  303. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`)
  304. reject()
  305. })
  306. socket.on('connect', () => {
  307. this.logger.info('Connection forwarded')
  308. const stream = accept()
  309. stream.pipe(socket)
  310. socket.pipe(stream)
  311. stream.on('close', () => {
  312. socket.destroy()
  313. })
  314. socket.on('close', () => {
  315. stream.close()
  316. })
  317. })
  318. })
  319. this.ssh.on('x11', async (details, accept, reject) => {
  320. this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`)
  321. const displaySpec = process.env.DISPLAY ?? 'localhost:0'
  322. this.logger.debug(`Trying display ${displaySpec}`)
  323. const socket = new X11Socket()
  324. try {
  325. const x11Stream = await socket.connect(displaySpec)
  326. this.logger.info('Connection forwarded')
  327. const stream = accept()
  328. stream.pipe(x11Stream)
  329. x11Stream.pipe(stream)
  330. stream.on('close', () => {
  331. socket.destroy()
  332. })
  333. x11Stream.on('close', () => {
  334. stream.close()
  335. })
  336. } catch (e) {
  337. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  338. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`)
  339. this.emitServiceMessage(` Tabby tried to connect to ${JSON.stringify(X11Socket.resolveDisplaySpec(displaySpec))} based on the DISPLAY environment var (${displaySpec})`)
  340. if (process.platform === 'win32') {
  341. this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:')
  342. this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/')
  343. this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/')
  344. }
  345. reject()
  346. }
  347. })
  348. }
  349. emitServiceMessage (msg: string): void {
  350. this.serviceMessage.next(msg)
  351. this.logger.info(stripAnsi(msg))
  352. }
  353. emitKeyboardInteractivePrompt (prompt: KeyboardInteractivePrompt): void {
  354. this.logger.info('Keyboard-interactive auth:', prompt.name, prompt.instruction)
  355. this.emitServiceMessage(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${prompt.name}`)
  356. if (prompt.instruction) {
  357. for (const line of prompt.instruction.split('\n')) {
  358. this.emitServiceMessage(line)
  359. }
  360. }
  361. this.keyboardInteractivePrompt.next(prompt)
  362. }
  363. async handleAuth (methodsLeft?: string[] | null): Promise<any> {
  364. this.activePrivateKey = null
  365. while (true) {
  366. const method = this.remainingAuthMethods.shift()
  367. if (!method) {
  368. return false
  369. }
  370. if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') {
  371. // Agent can still be used even if not in methodsLeft
  372. this.logger.info('Server does not support auth method', method.type)
  373. continue
  374. }
  375. if (method.type === 'password') {
  376. if (this.profile.options.password) {
  377. this.emitServiceMessage('Using preset password')
  378. return {
  379. type: 'password',
  380. username: this.authUsername,
  381. password: this.profile.options.password,
  382. }
  383. }
  384. if (!this.keychainPasswordUsed && this.profile.options.user) {
  385. const password = await this.passwordStorage.loadPassword(this.profile)
  386. if (password) {
  387. this.emitServiceMessage('Trying saved password')
  388. this.keychainPasswordUsed = true
  389. return {
  390. type: 'password',
  391. username: this.authUsername,
  392. password,
  393. }
  394. }
  395. }
  396. const modal = this.ngbModal.open(PromptModalComponent)
  397. modal.componentInstance.prompt = `Password for ${this.authUsername}@${this.profile.options.host}`
  398. modal.componentInstance.password = true
  399. modal.componentInstance.showRememberCheckbox = true
  400. try {
  401. const result = await modal.result
  402. if (result) {
  403. if (result.remember) {
  404. this.savedPassword = result.value
  405. }
  406. return {
  407. type: 'password',
  408. username: this.authUsername,
  409. password: result.value,
  410. }
  411. } else {
  412. continue
  413. }
  414. } catch {
  415. continue
  416. }
  417. }
  418. if (method.type === 'publickey') {
  419. try {
  420. const key = await this.loadPrivateKey(method.contents)
  421. return {
  422. type: 'publickey',
  423. username: this.authUsername,
  424. key,
  425. }
  426. } catch (e) {
  427. this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`)
  428. continue
  429. }
  430. }
  431. return method.type
  432. }
  433. }
  434. async addPortForward (fw: ForwardedPort): Promise<void> {
  435. if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
  436. await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => {
  437. this.logger.info(`New connection on ${fw}`)
  438. this.ssh.forwardOut(
  439. sourceAddress ?? '127.0.0.1',
  440. sourcePort ?? 0,
  441. targetAddress,
  442. targetPort,
  443. (err, stream) => {
  444. if (err) {
  445. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  446. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`)
  447. reject()
  448. return
  449. }
  450. const socket = accept()
  451. stream.pipe(socket)
  452. socket.pipe(stream)
  453. stream.on('close', () => {
  454. socket.destroy()
  455. })
  456. socket.on('close', () => {
  457. stream.close()
  458. })
  459. }
  460. )
  461. }).then(() => {
  462. this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`)
  463. this.forwardedPorts.push(fw)
  464. }).catch(e => {
  465. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`)
  466. throw e
  467. })
  468. }
  469. if (fw.type === PortForwardType.Remote) {
  470. await new Promise<void>((resolve, reject) => {
  471. this.ssh.forwardIn(fw.host, fw.port, err => {
  472. if (err) {
  473. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  474. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`)
  475. reject(err)
  476. return
  477. }
  478. resolve()
  479. })
  480. })
  481. this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`)
  482. this.forwardedPorts.push(fw)
  483. }
  484. }
  485. async removePortForward (fw: ForwardedPort): Promise<void> {
  486. if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
  487. fw.stopLocalListener()
  488. this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
  489. }
  490. if (fw.type === PortForwardType.Remote) {
  491. this.ssh.unforwardIn(fw.host, fw.port)
  492. this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
  493. }
  494. this.emitServiceMessage(`Stopped forwarding ${fw}`)
  495. }
  496. resize (columns: number, rows: number): void {
  497. if (this.shell) {
  498. this.shell.setWindow(rows, columns, rows, columns)
  499. }
  500. }
  501. write (data: Buffer): void {
  502. if (this.shell) {
  503. this.shell.write(data)
  504. }
  505. }
  506. kill (signal?: string): void {
  507. if (this.shell) {
  508. this.shell.signal(signal ?? 'TERM')
  509. }
  510. }
  511. async destroy (): Promise<void> {
  512. this.serviceMessage.complete()
  513. this.proxyCommandStream?.destroy()
  514. this.kill()
  515. this.ssh.end()
  516. await super.destroy()
  517. }
  518. async getChildProcesses (): Promise<any[]> {
  519. return []
  520. }
  521. async gracefullyKillProcess (): Promise<void> {
  522. this.kill('TERM')
  523. }
  524. supportsWorkingDirectory (): boolean {
  525. return !!this.reportedCWD
  526. }
  527. async getWorkingDirectory (): Promise<string|null> {
  528. return this.reportedCWD ?? null
  529. }
  530. private openShellChannel (options): Promise<ClientChannel> {
  531. return new Promise<ClientChannel>((resolve, reject) => {
  532. this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => {
  533. if (err) {
  534. reject(err)
  535. } else {
  536. resolve(shell)
  537. }
  538. })
  539. })
  540. }
  541. async loadPrivateKey (privateKeyContents?: Buffer): Promise<string|null> {
  542. if (!privateKeyContents) {
  543. const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
  544. if (await fs.exists(userKeyPath)) {
  545. this.emitServiceMessage('Using user\'s default private key')
  546. privateKeyContents = await fs.readFile(userKeyPath, { encoding: null })
  547. }
  548. }
  549. if (!privateKeyContents) {
  550. return null
  551. }
  552. this.emitServiceMessage('Loading private key')
  553. try {
  554. const parsedKey = await this.parsePrivateKey(privateKeyContents.toString())
  555. this.activePrivateKey = parsedKey.toString('openssh')
  556. return this.activePrivateKey
  557. } catch (error) {
  558. this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file')
  559. this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`)
  560. this.notifications.error('Could not read the private key file')
  561. return null
  562. }
  563. }
  564. async parsePrivateKey (privateKey: string): Promise<any> {
  565. const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex')
  566. let triedSavedPassphrase = false
  567. let passphrase: string|null = null
  568. while (true) {
  569. try {
  570. return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase })
  571. } catch (e) {
  572. if (!triedSavedPassphrase) {
  573. passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
  574. triedSavedPassphrase = true
  575. continue
  576. }
  577. if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) {
  578. await this.passwordStorage.deletePrivateKeyPassword(keyHash)
  579. const modal = this.ngbModal.open(PromptModalComponent)
  580. modal.componentInstance.prompt = 'Private key passphrase'
  581. modal.componentInstance.password = true
  582. modal.componentInstance.showRememberCheckbox = true
  583. try {
  584. const result = await modal.result
  585. passphrase = result?.value
  586. if (passphrase && result.remember) {
  587. this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase)
  588. }
  589. } catch {
  590. throw e
  591. }
  592. } else {
  593. this.notifications.error('Could not read the private key', e.toString())
  594. throw e
  595. }
  596. }
  597. }
  598. }
  599. }