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:
2026-04-17 00:00:00 +01:00
parent 1463394cbb
commit 2446e037ce
8 changed files with 334 additions and 5 deletions
+67
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"github.com/pquerna/otp/totp"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/copilot-ssh-client/app/internal/audit"
@@ -441,6 +442,72 @@ func (s *Service) DeleteProfileSecrets(profileID string) error {
return nil
}
// ---------------------------------------------------------------------------
// TOTP / 2FA
// ---------------------------------------------------------------------------
// SetTOTPSecret stores a TOTP secret for the given profile, encrypted in
// the secret vault (or OS keychain).
func (s *Service) SetTOTPSecret(profileID string, secret string) error {
return s.SetProfileSecret(profileID, "totp", secret)
}
// GetTOTPCode generates the current 6-digit TOTP code for the given profile.
// Used for the "Test" button in the profile editor.
func (s *Service) GetTOTPCode(profileID string) (string, error) {
// Look up the TOTP secret from the vault.
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
var secret string
if s.keychainStore != nil && s.keychainStore.Available() {
if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" {
secret = val
}
}
if secret == "" && s.secretStore != nil && s.secretStore.IsUnlocked() {
if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" {
secret = val
}
}
if secret == "" {
return "", fmt.Errorf("no TOTP secret configured for this profile")
}
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
return "", fmt.Errorf("failed to generate TOTP code: %w", err)
}
return code, nil
}
// HasTOTPSecret checks whether a profile has a TOTP secret stored.
func (s *Service) HasTOTPSecret(profileID string) bool {
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
if s.keychainStore != nil && s.keychainStore.Available() {
if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" {
return true
}
}
if s.secretStore != nil && s.secretStore.IsUnlocked() {
if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" {
return true
}
}
return false
}
// DeleteTOTPSecret removes the TOTP secret for the given profile.
func (s *Service) DeleteTOTPSecret(profileID string) error {
totpKey := fmt.Sprintf("profile:%s:totp", profileID)
if s.keychainStore != nil && s.keychainStore.Available() {
_ = s.keychainStore.DeleteSecret(totpKey)
}
if s.secretStore != nil && s.secretStore.IsUnlocked() {
_ = s.secretStore.DeleteSecret(totpKey)
}
return nil
}
// ---------------------------------------------------------------------------
// Known hosts
// ---------------------------------------------------------------------------
+1
View File
@@ -98,6 +98,7 @@ type SSHConfig struct {
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
JumpHosts []JumpHost `json:"jumpHosts"`
PortForwards []PortForward `json:"portForwards"`
StartupCommand string `json:"startupCommand"`
+15
View File
@@ -508,6 +508,21 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret)
getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret)
// Resolve TOTP secret from vault if not already inline.
if sshConf.TOTPSecret == "" {
totpKey := fmt.Sprintf("profile:%s:totp", profile.ID)
if m.keychainStore != nil && m.keychainStore.Available() {
if val, err := m.keychainStore.GetSecret(totpKey); err == nil && val != "" {
sshConf.TOTPSecret = val
}
}
if sshConf.TOTPSecret == "" && m.secretStore != nil && m.secretStore.IsUnlocked() {
if val, err := m.secretStore.GetSecret(totpKey); err == nil && val != "" {
sshConf.TOTPSecret = val
}
}
}
if err := client.Connect(getPassword, getPassphrase); err != nil {
return err
}
+47 -5
View File
@@ -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.