Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tabby-ssh/src/api/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions {
httpProxyPort?: number
reuseSession?: boolean
input: InputProcessingOptions,
totpSecret?: string
}

export enum PortForwardType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,18 @@
.prompt-text {
white-space: pre-wrap;
}

.totp-info {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a standard bootstrap alert component here (.alert.alert-success) so that it can respond to theme changes

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;
}
}
62 changes: 59 additions & 3 deletions tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,86 @@
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',
templateUrl: './keyboardInteractiveAuthPanel.component.pug',
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
@Output() done = new EventEmitter()
@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 {
Expand All @@ -41,6 +94,9 @@ export class KeyboardInteractiveAuthComponent {
return
}
this.step++
this.updateTOTPIfNeeded()
this.startTOTPTimer()
this.input.nativeElement.focus()
this.changeDetector.markForCheck()
}
}
9 changes: 9 additions & 0 deletions tabby-ssh/src/components/sshProfileSettings.component.pug
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tabby-ssh/src/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
httpProxyPort: null,
reuseSession: true,
input: { backspace: 'backspace' },
totpSecret: null,
},
clearServiceMessagesOnConnect: true,
}
Expand Down
108 changes: 108 additions & 0 deletions tabby-ssh/src/services/totp.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Injectable } from '@angular/core'
import * as crypto from 'crypto'

@Injectable({ providedIn: 'root' })
export class TOTPService {
/**
* 生成TOTP代码
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please translate all comments

* @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)
}
}
10 changes: 10 additions & 0 deletions tabby-ssh/src/session/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading