Add TOTP/2FA support for SSH keyboard-interactive auth
Automatically generate and submit TOTP codes during SSH keyboard-interactive authentication when a secret is configured for the profile. Backend: - Add TOTPSecret field to SSHConfig model - Detect TOTP/2FA prompts in keyboard-interactive handler using keyword matching (verification code, otp, authenticator, 2fa, etc.) - Generate 6-digit TOTP codes using pquerna/otp library - Retrieve TOTP secret from vault during connection (keychain or file vault) - API: SetTOTPSecret, GetTOTPCode, HasTOTPSecret, DeleteTOTPSecret Frontend: - Add TOTP/2FA section to SSH profile editor (collapsed by default) - Password-masked secret input with show/hide toggle - Save button: encrypts and stores in vault - Test button: generates a live 6-digit code (shown for 5 seconds) - Delete button: removes stored secret - Auto-detect whether secret is already saved (placeholder indicator) - CSS for TOTP input row, danger button style
This commit is contained in:
@@ -6,10 +6,12 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -355,15 +357,29 @@ func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (strin
|
||||
case "keyboard-interactive":
|
||||
if getPassword != nil {
|
||||
pwdCallback := getPassword
|
||||
totpSecret := ""
|
||||
if sc.TOTPSecret != "" {
|
||||
totpSecret = sc.TOTPSecret
|
||||
}
|
||||
methods = append(methods, ssh.KeyboardInteractive(
|
||||
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
ans, err := pwdCallback()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for i, question := range questions {
|
||||
q := strings.ToLower(question)
|
||||
// Detect TOTP/2FA prompts and auto-fill when secret is configured.
|
||||
if totpSecret != "" && isTOTPPrompt(q) {
|
||||
code, err := totp.GenerateCode(totpSecret, time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate TOTP code: %w", err)
|
||||
}
|
||||
answers[i] = code
|
||||
} else {
|
||||
ans, err := pwdCallback()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers[i] = ans
|
||||
}
|
||||
answers[i] = ans
|
||||
}
|
||||
return answers, nil
|
||||
},
|
||||
@@ -378,6 +394,32 @@ func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (strin
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
// isTOTPPrompt checks whether a keyboard-interactive question looks like a
|
||||
// TOTP/2FA prompt. Common patterns include "verification code", "otp",
|
||||
// "two-factor", "2fa", "authenticator", etc.
|
||||
func isTOTPPrompt(question string) bool {
|
||||
totpKeywords := []string{
|
||||
"verification code",
|
||||
"otp",
|
||||
"one-time",
|
||||
"one time",
|
||||
"two-factor",
|
||||
"two factor",
|
||||
"2fa",
|
||||
"totp",
|
||||
"authenticator",
|
||||
"security code",
|
||||
"6-digit",
|
||||
"6 digit",
|
||||
}
|
||||
for _, kw := range totpKeywords {
|
||||
if strings.Contains(question, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// connectViaJumpHosts establishes the SSH connection through a chain of
|
||||
// jump hosts. Each hop opens a new net.Conn through the previous hop's
|
||||
// SSH client.
|
||||
|
||||
Reference in New Issue
Block a user