Compare commits

...

9 Commits

Author SHA1 Message Date
NightWatcher314 d05bb0bd07
Merge a6f627bdac into 2cf6ef0036 2025-11-11 12:17:14 +01:00
Indigo.liu 2cf6ef0036
Add support to connect in winscp throug tunnel. (#10829)
Package-Build / Lint (push) Has been cancelled Details
Docs / build (push) Has been cancelled Details
Package-Build / macOS-Build (arm64, aarch64-apple-darwin) (push) Has been cancelled Details
Package-Build / macOS-Build (x86_64, x86_64-apple-darwin) (push) Has been cancelled Details
Package-Build / Linux-Build (amd64, x64, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled Details
Package-Build / Linux-Build (arm64, arm64, ubuntu-24.04-arm, aarch64-unknown-linux-gnu, aarch64-linux-gnu-) (push) Has been cancelled Details
Package-Build / Linux-Build (armhf, arm, ubuntu-24.04, arm-unknown-linux-gnueabihf, arm-linux-gnueabihf-) (push) Has been cancelled Details
Package-Build / Windows-Build (arm64, aarch64-pc-windows-msvc) (push) Has been cancelled Details
Package-Build / Windows-Build (x64, x86_64-pc-windows-msvc) (push) Has been cancelled Details
CodeQL / Analyze (javascript) (push) Has been cancelled Details
2025-11-11 12:14:42 +01:00
Kairlec 7a7d3d2b77
fix: unexpected configuration was selected in the recent profiles (#10817) 2025-11-11 12:09:09 +01:00
Eugene 4f14e92e6a
bump node-pty 2025-11-11 11:54:47 +01:00
Eugene 52463555ab
Disable SSH compression by default as a workaround for SFTP upload bugs 2025-11-11 11:35:01 +01:00
NightWatcher314 a6f627bdac
Merge branch 'Eugeny:master' into master 2025-10-14 03:49:33 +08:00
NightWatcher314 13eab0b378
Merge branch 'Eugeny:master' into master 2025-07-29 17:22:19 +08:00
NightWatcher 6ffb1550f6 fix lint 2025-06-07 21:49:08 +08:00
NightWatcher 1ae436ffdf add totp support 2025-06-07 21:18:32 +08:00
13 changed files with 318 additions and 43 deletions

View File

@ -16,7 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@electron/remote": "^2", "@electron/remote": "^2",
"node-pty": "^1.1.0-beta34", "node-pty": "^1.1.0-beta39",
"any-promise": "^1.3.0", "any-promise": "^1.3.0",
"electron-config": "2.0.0", "electron-config": "2.0.0",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",

View File

@ -2813,7 +2813,7 @@ node-gyp@^10.0.0, node-gyp@^5.0.2, node-gyp@^5.1.0:
tar "^6.1.2" tar "^6.1.2"
which "^4.0.0" which "^4.0.0"
node-pty@^1.1.0-beta34: node-pty@^1.1.0-beta39:
version "1.1.0-beta9" version "1.1.0-beta9"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d" resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d"
integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw== integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==

View File

@ -2,7 +2,6 @@ import * as fs from 'fs/promises'
import * as fsSync from 'fs' import * as fsSync from 'fs'
import * as path from 'path' import * as path from 'path'
import * as glob from 'glob' import * as glob from 'glob'
import slugify from 'slugify'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { PartialProfile } from 'tabby-core' import { PartialProfile } from 'tabby-core'
@ -145,15 +144,24 @@ async function parseSSHConfigFile (
return merged return merged
} }
// Function to convert an SSH Profile name into a sha256 hash-based ID
async function hashSSHProfileName (name: string) {
const textEncoder = new TextEncoder()
const encoded = textEncoder.encode(name)
const hash = await crypto.subtle.digest('SHA-256', encoded)
const hashArray = Array.from(new Uint8Array(hash))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
// Function to take an ssh-config entry and convert it into an SSHProfile // Function to take an ssh-config entry and convert it into an SSHProfile
function convertHostToSSHProfile (host: string, settings: Record<string, string | string[] | object[] >): PartialProfile<SSHProfile> { async function convertHostToSSHProfile (host: string, settings: Record<string, string | string[] | object[] >): Promise<PartialProfile<SSHProfile>> {
// inline function to generate an id for this profile // inline function to generate an id for this profile
const deriveID = (name: string) => 'openssh-config:' + slugify(name) const deriveID = async (name: string) => 'openssh-config:' + await hashSSHProfileName(name)
// Start point of the profile, with an ID, name, type and group // Start point of the profile, with an ID, name, type and group
const thisProfile: PartialProfile<SSHProfile> = { const thisProfile: PartialProfile<SSHProfile> = {
id: deriveID(host), id: await deriveID(host),
name: `${host} (.ssh/config)`, name: `${host} (.ssh/config)`,
type: 'ssh', type: 'ssh',
group: 'Imported from .ssh/config', group: 'Imported from .ssh/config',
@ -194,7 +202,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
const basicString = settings[key] const basicString = settings[key]
if (typeof basicString === 'string') { if (typeof basicString === 'string') {
if (targetName === SSHProfilePropertyNames.JumpHost) { if (targetName === SSHProfilePropertyNames.JumpHost) {
options[targetName] = deriveID(basicString) options[targetName] = await deriveID(basicString)
} else { } else {
options[targetName] = basicString options[targetName] = basicString
} }
@ -295,7 +303,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
return thisProfile return thisProfile
} }
function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[] { async function convertToSSHProfiles (config: SSHConfig): Promise<PartialProfile<SSHProfile>[]> {
const myMap = new Map<string, PartialProfile<SSHProfile>>() const myMap = new Map<string, PartialProfile<SSHProfile>>()
function noWildCardsInName (name: string) { function noWildCardsInName (name: string) {
@ -333,7 +341,7 @@ function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[]
// NOTE: SSHConfig.compute() lies about the return types // NOTE: SSHConfig.compute() lies about the return types
const configuration: Record<string, string | string[] | object[]> = config.compute(host) const configuration: Record<string, string | string[] | object[]> = config.compute(host)
if (Object.keys(configuration).map(key => key.toLowerCase()).includes('hostname')) { if (Object.keys(configuration).map(key => key.toLowerCase()).includes('hostname')) {
myMap[host] = convertHostToSSHProfile(host, configuration) myMap[host] = await convertHostToSSHProfile(host, configuration)
} }
} }
} }
@ -354,7 +362,7 @@ export class OpenSSHImporter extends SSHProfileImporter {
try { try {
const config: SSHConfig = await parseSSHConfigFile(configPath) const config: SSHConfig = await parseSSHConfigFile(configPath)
return convertToSSHProfiles(config) return await convertToSSHProfiles(config)
} catch (e) { } catch (e) {
if (e.code === 'ENOENT') { if (e.code === 'ENOENT') {
return [] return []
@ -376,7 +384,7 @@ export class StaticFileImporter extends SSHProfileImporter {
} }
async getProfiles (): Promise<PartialProfile<SSHProfile>[]> { async getProfiles (): Promise<PartialProfile<SSHProfile>[]> {
const deriveID = name => 'file-config:' + slugify(name) const deriveID = async name => 'file-config:' + await hashSSHProfileName(name)
if (!fsSync.existsSync(this.configPath)) { if (!fsSync.existsSync(this.configPath)) {
return [] return []
@ -387,11 +395,11 @@ export class StaticFileImporter extends SSHProfileImporter {
return [] return []
} }
return (yaml.load(content) as PartialProfile<SSHProfile>[]).map(item => ({ return Promise.all((yaml.load(content) as PartialProfile<SSHProfile>[]).map(async item => ({
...item, ...item,
id: deriveID(item.name), id: await deriveID(item.name),
type: 'ssh', type: 'ssh',
})) })))
} }
} }

View File

@ -45,8 +45,6 @@ export const defaultAlgorithms = {
'hmac-sha1', 'hmac-sha1',
], ],
[SSHAlgorithmType.COMPRESSION]: [ [SSHAlgorithmType.COMPRESSION]: [
'zlib@openssh.com',
'zlib',
'none', 'none',
], ],
} }

View File

@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions {
httpProxyPort?: number httpProxyPort?: number
reuseSession?: boolean reuseSession?: boolean
input: InputProcessingOptions, input: InputProcessingOptions,
totpSecret?: string
} }
export enum PortForwardType { export enum PortForwardType {

View File

@ -4,6 +4,16 @@
.prompt-text {{prompt.prompts[step].prompt}} .prompt-text {{prompt.prompts[step].prompt}}
.totp-info.mt-2(*ngIf='isTOTP() && profile.options.totpSecret')
.d-flex.align-items-center
.totp-code.me-3
strong {{totpCode}}
.totp-timer
small {{totpTimeRemaining}}s remaining
.ms-auto
small.text-muted Auto-filled
input.form-control.mt-2( input.form-control.mt-2(
#input, #input,
autofocus, autofocus,

View File

@ -7,3 +7,18 @@
.prompt-text { .prompt-text {
white-space: pre-wrap; white-space: pre-wrap;
} }
.totp-info {
background: rgba(40, 167, 69, 0.1);
border: 1px solid rgba(40, 167, 69, 0.3);
border-radius: 4px;
padding: 8px 12px;
.totp-code {
color: #28a745;
}
.totp-timer {
color: #6c757d;
}
}

View File

@ -1,7 +1,8 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core' import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core'
import { KeyboardInteractivePrompt } from '../session/ssh' import { KeyboardInteractivePrompt } from '../session/ssh'
import { SSHProfile } from '../api' import { SSHProfile } from '../api'
import { PasswordStorageService } from '../services/passwordStorage.service' import { PasswordStorageService } from '../services/passwordStorage.service'
import { TOTPService } from '../services/totp.service'
@Component({ @Component({
selector: 'keyboard-interactive-auth-panel', selector: 'keyboard-interactive-auth-panel',
@ -9,7 +10,7 @@ import { PasswordStorageService } from '../services/passwordStorage.service'
styleUrls: ['./keyboardInteractiveAuthPanel.component.scss'], styleUrls: ['./keyboardInteractiveAuthPanel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class KeyboardInteractiveAuthComponent { export class KeyboardInteractiveAuthComponent implements OnInit, OnDestroy {
@Input() profile: SSHProfile @Input() profile: SSHProfile
@Input() prompt: KeyboardInteractivePrompt @Input() prompt: KeyboardInteractivePrompt
@Input() step = 0 @Input() step = 0
@ -17,17 +18,69 @@ export class KeyboardInteractiveAuthComponent {
@ViewChild('input') input: ElementRef @ViewChild('input') input: ElementRef
remember = false remember = false
constructor (private passwordStorage: PasswordStorageService) {} totpCode = ''
totpTimeRemaining = 30
private totpInterval?: any
constructor (
private passwordStorage: PasswordStorageService,
private totpService: TOTPService,
private changeDetector: ChangeDetectorRef,
) {}
ngOnInit (): void {
this.updateTOTPIfNeeded()
this.startTOTPTimer()
this.changeDetector.markForCheck()
}
ngOnDestroy (): void {
if (this.totpInterval) {
clearInterval(this.totpInterval)
}
}
isPassword (): boolean { isPassword (): boolean {
return this.prompt.isAPasswordPrompt(this.step) return this.prompt.isAPasswordPrompt(this.step)
} }
isTOTP (): boolean {
return this.prompt.isTOTPPrompt(this.step)
}
private updateTOTPIfNeeded (): void {
if (this.isTOTP() && this.profile.options.totpSecret) {
try {
this.totpCode = this.totpService.generateTOTP(this.profile.options.totpSecret)
this.prompt.responses[this.step] = this.totpCode
this.changeDetector.markForCheck()
} catch (error) {
console.error('Failed to generate TOTP:', error)
}
}
}
private startTOTPTimer (): void {
if (this.isTOTP() && this.profile.options.totpSecret) {
this.totpInterval = setInterval(() => {
this.totpTimeRemaining = this.totpService.getRemainingTime()
if (this.totpTimeRemaining === 30) {
// 生成新的TOTP代码
this.updateTOTPIfNeeded()
}
this.changeDetector.markForCheck()
}, 1000)
}
}
previous (): void { previous (): void {
if (this.step > 0) { if (this.step > 0) {
this.step-- this.step--
this.updateTOTPIfNeeded()
this.startTOTPTimer()
} }
this.input.nativeElement.focus() this.input.nativeElement.focus()
this.changeDetector.markForCheck()
} }
next (): void { next (): void {
@ -41,6 +94,9 @@ export class KeyboardInteractiveAuthComponent {
return return
} }
this.step++ this.step++
this.updateTOTPIfNeeded()
this.startTOTPTimer()
this.input.nativeElement.focus() this.input.nativeElement.focus()
this.changeDetector.markForCheck()
} }
} }

View File

@ -255,6 +255,15 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
[(ngModel)]='profile.options.readyTimeout', [(ngModel)]='profile.options.readyTimeout',
) )
.form-line(*ngIf='profile.options.user && (!profile.options.auth || profile.options.auth === "keyboardInteractive" || profile.options.auth === "password")')
.header
.title TOTP Secret Key
input.form-control(
type='password',
placeholder='Enter your TOTP secret key (Base32)',
[(ngModel)]='profile.options.totpSecret'
)
li(ngbNavItem) li(ngbNavItem)
a(ngbNavLink, translate) Ciphers a(ngbNavLink, translate) Ciphers
ng-template(ngbNavContent) ng-template(ngbNavContent)

View File

@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
httpProxyPort: null, httpProxyPort: null,
reuseSession: true, reuseSession: true,
input: { backspace: 'backspace' }, input: { backspace: 'backspace' },
totpSecret: null,
}, },
clearServiceMessagesOnConnect: true, clearServiceMessagesOnConnect: true,
} }

View File

@ -27,18 +27,89 @@ export class SSHService {
return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
} }
async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<string> { async generateWinSCPXTunnelURI (jumpHostProfile: SSHProfile|null): Promise<{ uri: string|null, privateKeyFile?: tmp.FileResult|null }> {
let uri = ''
let tmpFile: tmp.FileResult|null = null
if (jumpHostProfile) {
uri += ';x-tunnel=1'
const jumpHostname = jumpHostProfile.options.host
uri += `;x-tunnelhostname=${jumpHostname}`
const jumpPort = jumpHostProfile.options.port ?? 22
uri += `;x-tunnelportnumber=${jumpPort}`
const jumpUsername = jumpHostProfile.options.user
uri += `;x-tunnelusername=${jumpUsername}`
if (jumpHostProfile.options.auth === 'password') {
const jumpPassword = await this.passwordStorage.loadPassword(jumpHostProfile, jumpUsername)
if (jumpPassword) {
uri += `;x-tunnelpasswordplain=${encodeURIComponent(jumpPassword)}`
}
}
if (jumpHostProfile.options.auth === 'publicKey' && jumpHostProfile.options.privateKeys && jumpHostProfile.options.privateKeys.length > 0) {
const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(jumpHostProfile)
tmpFile = privateKeyPairs.privateKeyFile
if (tmpFile) {
uri += `;x-tunnelpublickeyfile=${encodeURIComponent(tmpFile.path)}`
}
if (privateKeyPairs.passphrase != null) {
uri += `;x-tunnelpassphraseplain=${encodeURIComponent(privateKeyPairs.passphrase)}`
}
}
}
return { uri: uri, privateKeyFile: tmpFile?? null }
}
async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<{ uri: string, privateKeyFile?: tmp.FileResult|null }> {
let uri = `scp://${username ?? profile.options.user}` let uri = `scp://${username ?? profile.options.user}`
const password = await this.passwordStorage.loadPassword(profile, username) const password = await this.passwordStorage.loadPassword(profile, username)
if (password) { if (password) {
uri += ':' + encodeURIComponent(password) uri += ':' + encodeURIComponent(password)
} }
let tmpFile: tmp.FileResult|null = null
if (profile.options.jumpHost) {
const jumpHostProfile = this.config.store.profiles.find(x => x.id === profile.options.jumpHost) ?? null
const xTunnelParams = await this.generateWinSCPXTunnelURI(jumpHostProfile)
uri += xTunnelParams.uri ?? ''
tmpFile = xTunnelParams.privateKeyFile ?? null
}
if (profile.options.host.includes(':')) { if (profile.options.host.includes(':')) {
uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}` uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}`
}else { }else {
uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}` uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}`
} }
return uri return { uri, privateKeyFile: tmpFile?? null }
}
async convertPrivateKeyFileToPuTTYFormat (profile: SSHProfile): Promise<{ passphrase: string|null, privateKeyFile: tmp.FileResult|null }> {
if (!profile.options.privateKeys || profile.options.privateKeys.length === 0) {
throw new Error('No private keys in profile')
}
const path = this.getWinSCPPath()
if (!path) {
throw new Error('WinSCP not found')
}
let tmpPrivateKeyFile: tmp.FileResult|null = null
let passphrase: string|null = null
const tmpFile: tmp.FileResult = await tmp.file()
for (const pk of profile.options.privateKeys) {
let privateKeyContent: string|null = null
const buffer = await this.fileProviders.retrieveFile(pk)
privateKeyContent = buffer.toString()
await fs.writeFile(tmpFile.path, privateKeyContent)
const keyHash = crypto.createHash('sha512').update(privateKeyContent).digest('hex')
// need to pass an default passphrase, otherwise it might get stuck at the passphrase input
const curPassphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) ?? 'tabby'
const winSCPcom = path.slice(0, -3) + 'com'
try {
await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, '-o', tmpFile.path, '--old-passphrase', curPassphrase])
} catch (error) {
console.warn('Could not convert private key ', error)
continue
}
tmpPrivateKeyFile = tmpFile
passphrase = curPassphrase
break
}
return { passphrase, privateKeyFile: tmpPrivateKeyFile }
} }
async launchWinSCP (session: SSHSession): Promise<void> { async launchWinSCP (session: SSHSession): Promise<void> {
@ -46,38 +117,26 @@ export class SSHService {
if (!path) { if (!path) {
return return
} }
const args = [await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)] const winscpParms = await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)
const args = [winscpParms.uri]
let tmpFile: tmp.FileResult|null = null let tmpFile: tmp.FileResult|null = null
try { try {
if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) { if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) {
tmpFile = await tmp.file() const profile = session.profile
let passphrase: string|null = null const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(profile)
for (const pk of session.profile.options.privateKeys) { tmpFile = privateKeyPairs.privateKeyFile
let privateKeyContent: string|null = null if (tmpFile) {
const buffer = await this.fileProviders.retrieveFile(pk) args.push(`/privatekey=${tmpFile.path}`)
privateKeyContent = buffer.toString()
await fs.writeFile(tmpFile.path, privateKeyContent)
const keyHash = crypto.createHash('sha512').update(privateKeyContent).digest('hex')
// need to pass an default passphrase, otherwise it might get stuck at the passphrase input
passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) ?? 'tabby'
const winSCPcom = path.slice(0, -3) + 'com'
try {
await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, '-o', tmpFile.path, '--old-passphrase', passphrase])
} catch (error) {
console.warn('Could not convert private key ', error)
continue
}
break
} }
args.push(`/privatekey=${tmpFile.path}`) if (privateKeyPairs.passphrase != null) {
if (passphrase != null) { args.push(`/passphrase=${privateKeyPairs.passphrase}`)
args.push(`/passphrase=${passphrase}`)
} }
} }
await this.platform.exec(path, args) await this.platform.exec(path, args)
} finally { } finally {
tmpFile?.cleanup() tmpFile?.cleanup()
winscpParms.privateKeyFile?.cleanup()
} }
} }
} }

View File

@ -0,0 +1,108 @@
import { Injectable } from '@angular/core'
import * as crypto from 'crypto'
@Injectable({ providedIn: 'root' })
export class TOTPService {
/**
* TOTP代码
* @param secret Base32编码的密钥
* @param window 30
* @param digits 6
*/
generateTOTP (secret: string, window = 30, digits = 6): string {
if (!secret) {
throw new Error('TOTP secret is required')
}
try {
// 解码Base32密钥
const key = this.base32Decode(secret.toUpperCase().replace(/\s/g, ''))
// 计算时间步长
const epoch = Math.floor(Date.now() / 1000)
const timeStep = Math.floor(epoch / window)
// 生成HMAC
const hmac = crypto.createHmac('sha1', key as any)
const timeBuffer = Buffer.alloc(8)
timeBuffer.writeUInt32BE(0, 0)
timeBuffer.writeUInt32BE(timeStep, 4)
hmac.update(timeBuffer as any)
const hash = hmac.digest()
// 动态截取
const offset = hash[hash.length - 1] & 0x0f
const binary = (hash[offset] & 0x7f) << 24 |
(hash[offset + 1] & 0xff) << 16 |
(hash[offset + 2] & 0xff) << 8 |
hash[offset + 3] & 0xff
// 生成代码
const otp = binary % Math.pow(10, digits)
return otp.toString().padStart(digits, '0')
} catch (error) {
throw new Error(`Failed to generate TOTP: ${error.message}`)
}
}
/**
* TOTP密钥格式
*/
validateSecret (secret: string): boolean {
if (!secret) { return false }
try {
// 移除空格并转为大写
const cleanSecret = secret.toUpperCase().replace(/\s/g, '')
// 检查Base32字符
const base32Regex = /^[A-Z2-7]+=*$/
if (!base32Regex.test(cleanSecret)) {
return false
}
// 尝试解码
this.base32Decode(cleanSecret)
return true
} catch {
return false
}
}
/**
*
*/
getRemainingTime (window = 30): number {
const epoch = Math.floor(Date.now() / 1000)
return window - epoch % window
}
/**
* Base32解码
*/
private base32Decode (encoded: string): Uint8Array {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
let bits = 0
let value = 0
const output: number[] = []
for (const char of encoded) {
if (char === '=') { break }
const index = alphabet.indexOf(char)
if (index === -1) {
throw new Error(`Invalid character in Base32: ${char}`)
}
value = value << 5 | index
bits += 5
if (bits >= 8) {
output.push(value >>> bits - 8)
bits -= 8
}
}
return new Uint8Array(output)
}
}

View File

@ -84,6 +84,16 @@ export class KeyboardInteractivePrompt {
return this.prompts[index].prompt.toLowerCase().includes('password') && !this.prompts[index].echo return this.prompts[index].prompt.toLowerCase().includes('password') && !this.prompts[index].echo
} }
isTOTPPrompt (index: number): boolean {
const prompt = this.prompts[index].prompt.toLowerCase()
return (prompt.includes('verification code') ||
prompt.includes('authenticator') ||
prompt.includes('totp') ||
prompt.includes('token') ||
prompt.includes('code') ||
prompt.includes('otp')) && !this.prompts[index].echo
}
respond (): void { respond (): void {
this._resolve(this.responses) this._resolve(this.responses)
} }