mirror of https://github.com/Eugeny/tabby.git
Compare commits
9 Commits
76e2aefc74
...
d05bb0bd07
| Author | SHA1 | Date |
|---|---|---|
|
|
d05bb0bd07 | |
|
|
2cf6ef0036 | |
|
|
7a7d3d2b77 | |
|
|
4f14e92e6a | |
|
|
52463555ab | |
|
|
a6f627bdac | |
|
|
13eab0b378 | |
|
|
6ffb1550f6 | |
|
|
1ae436ffdf |
|
|
@ -16,7 +16,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "^2",
|
||||
"node-pty": "^1.1.0-beta34",
|
||||
"node-pty": "^1.1.0-beta39",
|
||||
"any-promise": "^1.3.0",
|
||||
"electron-config": "2.0.0",
|
||||
"electron-debug": "^3.2.0",
|
||||
|
|
|
|||
|
|
@ -2813,7 +2813,7 @@ node-gyp@^10.0.0, node-gyp@^5.0.2, node-gyp@^5.1.0:
|
|||
tar "^6.1.2"
|
||||
which "^4.0.0"
|
||||
|
||||
node-pty@^1.1.0-beta34:
|
||||
node-pty@^1.1.0-beta39:
|
||||
version "1.1.0-beta9"
|
||||
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d"
|
||||
integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import * as fs from 'fs/promises'
|
|||
import * as fsSync from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as glob from 'glob'
|
||||
import slugify from 'slugify'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { PartialProfile } from 'tabby-core'
|
||||
|
|
@ -145,15 +144,24 @@ async function parseSSHConfigFile (
|
|||
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 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
|
||||
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
|
||||
const thisProfile: PartialProfile<SSHProfile> = {
|
||||
id: deriveID(host),
|
||||
id: await deriveID(host),
|
||||
name: `${host} (.ssh/config)`,
|
||||
type: 'ssh',
|
||||
group: 'Imported from .ssh/config',
|
||||
|
|
@ -194,7 +202,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
|
|||
const basicString = settings[key]
|
||||
if (typeof basicString === 'string') {
|
||||
if (targetName === SSHProfilePropertyNames.JumpHost) {
|
||||
options[targetName] = deriveID(basicString)
|
||||
options[targetName] = await deriveID(basicString)
|
||||
} else {
|
||||
options[targetName] = basicString
|
||||
}
|
||||
|
|
@ -295,7 +303,7 @@ function convertHostToSSHProfile (host: string, settings: Record<string, string
|
|||
return thisProfile
|
||||
}
|
||||
|
||||
function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[] {
|
||||
async function convertToSSHProfiles (config: SSHConfig): Promise<PartialProfile<SSHProfile>[]> {
|
||||
const myMap = new Map<string, PartialProfile<SSHProfile>>()
|
||||
|
||||
function noWildCardsInName (name: string) {
|
||||
|
|
@ -333,7 +341,7 @@ function convertToSSHProfiles (config: SSHConfig): PartialProfile<SSHProfile>[]
|
|||
// NOTE: SSHConfig.compute() lies about the return types
|
||||
const configuration: Record<string, string | string[] | object[]> = config.compute(host)
|
||||
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 {
|
||||
const config: SSHConfig = await parseSSHConfigFile(configPath)
|
||||
return convertToSSHProfiles(config)
|
||||
return await convertToSSHProfiles(config)
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return []
|
||||
|
|
@ -376,7 +384,7 @@ export class StaticFileImporter extends SSHProfileImporter {
|
|||
}
|
||||
|
||||
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)) {
|
||||
return []
|
||||
|
|
@ -387,11 +395,11 @@ export class StaticFileImporter extends SSHProfileImporter {
|
|||
return []
|
||||
}
|
||||
|
||||
return (yaml.load(content) as PartialProfile<SSHProfile>[]).map(item => ({
|
||||
return Promise.all((yaml.load(content) as PartialProfile<SSHProfile>[]).map(async item => ({
|
||||
...item,
|
||||
id: deriveID(item.name),
|
||||
id: await deriveID(item.name),
|
||||
type: 'ssh',
|
||||
}))
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ export const defaultAlgorithms = {
|
|||
'hmac-sha1',
|
||||
],
|
||||
[SSHAlgorithmType.COMPRESSION]: [
|
||||
'zlib@openssh.com',
|
||||
'zlib',
|
||||
'none',
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions {
|
|||
httpProxyPort?: number
|
||||
reuseSession?: boolean
|
||||
input: InputProcessingOptions,
|
||||
totpSecret?: string
|
||||
}
|
||||
|
||||
export enum PortForwardType {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@
|
|||
|
||||
.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,
|
||||
autofocus,
|
||||
|
|
|
|||
|
|
@ -7,3 +7,18 @@
|
|||
.prompt-text {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { SSHProfile } from '../api'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { TOTPService } from '../services/totp.service'
|
||||
|
||||
@Component({
|
||||
selector: 'keyboard-interactive-auth-panel',
|
||||
|
|
@ -9,7 +10,7 @@ import { PasswordStorageService } from '../services/passwordStorage.service'
|
|||
styleUrls: ['./keyboardInteractiveAuthPanel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class KeyboardInteractiveAuthComponent {
|
||||
export class KeyboardInteractiveAuthComponent implements OnInit, OnDestroy {
|
||||
@Input() profile: SSHProfile
|
||||
@Input() prompt: KeyboardInteractivePrompt
|
||||
@Input() step = 0
|
||||
|
|
@ -17,17 +18,69 @@ export class KeyboardInteractiveAuthComponent {
|
|||
@ViewChild('input') input: ElementRef
|
||||
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 {
|
||||
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 {
|
||||
if (this.step > 0) {
|
||||
this.step--
|
||||
this.updateTOTPIfNeeded()
|
||||
this.startTOTPTimer()
|
||||
}
|
||||
this.input.nativeElement.focus()
|
||||
this.changeDetector.markForCheck()
|
||||
}
|
||||
|
||||
next (): void {
|
||||
|
|
@ -41,6 +94,9 @@ export class KeyboardInteractiveAuthComponent {
|
|||
return
|
||||
}
|
||||
this.step++
|
||||
this.updateTOTPIfNeeded()
|
||||
this.startTOTPTimer()
|
||||
this.input.nativeElement.focus()
|
||||
this.changeDetector.markForCheck()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,15 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
|
|||
[(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)
|
||||
a(ngbNavLink, translate) Ciphers
|
||||
ng-template(ngbNavContent)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
|
|||
httpProxyPort: null,
|
||||
reuseSession: true,
|
||||
input: { backspace: 'backspace' },
|
||||
totpSecret: null,
|
||||
},
|
||||
clearServiceMessagesOnConnect: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,18 +27,89 @@ export class SSHService {
|
|||
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}`
|
||||
const password = await this.passwordStorage.loadPassword(profile, username)
|
||||
if (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(':')) {
|
||||
uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}`
|
||||
}else {
|
||||
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> {
|
||||
|
|
@ -46,38 +117,26 @@ export class SSHService {
|
|||
if (!path) {
|
||||
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
|
||||
try {
|
||||
if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) {
|
||||
tmpFile = await tmp.file()
|
||||
let passphrase: string|null = null
|
||||
for (const pk of session.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
|
||||
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
|
||||
const profile = session.profile
|
||||
const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(profile)
|
||||
tmpFile = privateKeyPairs.privateKeyFile
|
||||
if (tmpFile) {
|
||||
args.push(`/privatekey=${tmpFile.path}`)
|
||||
}
|
||||
args.push(`/privatekey=${tmpFile.path}`)
|
||||
if (passphrase != null) {
|
||||
args.push(`/passphrase=${passphrase}`)
|
||||
if (privateKeyPairs.passphrase != null) {
|
||||
args.push(`/passphrase=${privateKeyPairs.passphrase}`)
|
||||
}
|
||||
}
|
||||
await this.platform.exec(path, args)
|
||||
} finally {
|
||||
tmpFile?.cleanup()
|
||||
winscpParms.privateKeyFile?.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,16 @@ export class KeyboardInteractivePrompt {
|
|||
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 {
|
||||
this._resolve(this.responses)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue