Compare commits

...

5 Commits

Author SHA1 Message Date
NightWatcher314 76e2aefc74
Merge a6f627bdac into bbcb026433 2025-10-20 09:55:33 +02: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
8 changed files with 213 additions and 3 deletions

View File

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

View File

@ -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,

View File

@ -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;
}
}

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 { 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()
}
}

View File

@ -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)

View File

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

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
}
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)
}