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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user