diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index eb3fd0b825..4533ba041b 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions { httpProxyPort?: number reuseSession?: boolean input: InputProcessingOptions, + totpSecret?: string } export enum PortForwardType { diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug index c6ab0700e8..0e3ba49eab 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug @@ -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, diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss index 34118097fb..51f965393a 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss @@ -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; + } +} diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts index bb2a851d67..dd3830561d 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts @@ -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() } } diff --git a/tabby-ssh/src/components/sshProfileSettings.component.pug b/tabby-ssh/src/components/sshProfileSettings.component.pug index 60a2bb7d99..0152c3a2d6 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.pug +++ b/tabby-ssh/src/components/sshProfileSettings.component.pug @@ -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) diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index cea2dac128..3f5fecf81f 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider httpProxyPort: null, reuseSession: true, input: { backspace: 'backspace' }, + totpSecret: null, }, clearServiceMessagesOnConnect: true, } diff --git a/tabby-ssh/src/services/totp.service.ts b/tabby-ssh/src/services/totp.service.ts new file mode 100644 index 0000000000..590201bc68 --- /dev/null +++ b/tabby-ssh/src/services/totp.service.ts @@ -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) + } +} diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index a1b78823f6..60c997e21f 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -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) }