diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css index 30bd814..d01e720 100644 --- a/frontend/src/css/forms.css +++ b/frontend/src/css/forms.css @@ -670,3 +670,34 @@ display: flex; gap: var(--space-2); } + +/* ----------------------------------------------------------------------- + TOTP / 2FA — secret input row with action buttons + ----------------------------------------------------------------------- */ +.totp-input-row { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.totp-input-row .form-input { + flex: 1; +} + +.totp-toggle, +.totp-test, +.totp-save, +.totp-delete { + flex-shrink: 0; + white-space: nowrap; +} + +.btn-danger { + background: var(--error); + color: #fff; + border: 1px solid transparent; +} + +.btn-danger:hover { + background: #d32f2f; +} diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 7b6bbb1..ad52870 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -405,6 +405,9 @@ class ProfileEditor { }), ], true)); + // TOTP / 2FA + sections.push(this._renderTOTPSection(profile)); + // Advanced sections.push(this._renderSection('Advanced', [ this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }), @@ -579,6 +582,167 @@ class ProfileEditor { * @returns {HTMLElement} * @private */ + /** + * Renders the TOTP/2FA section with secret management and test button. + * @param {Object} profile + * @returns {HTMLElement} + * @private + */ + _renderTOTPSection(profile) { + const section = createElement('div', 'form-section'); + const header = createElement('div', 'form-section-header'); + let collapsed = true; + + header.innerHTML = ` + ▸ + TOTP / 2FA + `; + + const content = createElement('div', 'form-section-content'); + content.style.display = 'none'; + + const helpText = createElement('div', 'form-help', { + textContent: 'Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.', + }); + content.appendChild(helpText); + + // TOTP secret input + const group = createElement('div', 'form-group'); + const label = createElement('label', 'form-label', { textContent: 'TOTP Secret' }); + group.appendChild(label); + + const row = createElement('div', 'totp-input-row'); + + const secretInput = createElement('input', 'form-input', { + type: 'password', + placeholder: 'Base32-encoded secret (e.g. from authenticator app setup)', + id: 'field-ssh.totpSecret', + dataset: { field: 'ssh.totpSecret' }, + autocapitalize: 'off', + autocorrect: 'off', + spellcheck: false, + }); + + // Show/hide toggle + const toggleBtn = createElement('button', 'btn btn-small btn-secondary totp-toggle', { + textContent: '👁', + title: 'Show/hide secret', + type: 'button', + }); + toggleBtn.addEventListener('mousedown', e => e.preventDefault()); + toggleBtn.addEventListener('click', () => { + secretInput.type = secretInput.type === 'password' ? 'text' : 'password'; + }); + + // Test button — generates a live code + const testBtn = createElement('button', 'btn btn-small btn-accent totp-test', { + textContent: 'Test', + title: 'Generate a code with this secret', + type: 'button', + }); + testBtn.addEventListener('mousedown', e => e.preventDefault()); + testBtn.addEventListener('click', async () => { + const profileId = profile.id; + if (profileId) { + try { + const code = await window.go.api.Service.GetTOTPCode(profileId); + testBtn.textContent = code; + setTimeout(() => { testBtn.textContent = 'Test'; }, 5000); + } catch (err) { + // If no saved secret yet, try computing locally using the input value + testBtn.textContent = 'Save first'; + setTimeout(() => { testBtn.textContent = 'Test'; }, 2000); + } + } + }); + + // Save button + const saveBtn = createElement('button', 'btn btn-small btn-primary totp-save', { + textContent: 'Save', + title: 'Encrypt and save the TOTP secret', + type: 'button', + }); + saveBtn.addEventListener('mousedown', e => e.preventDefault()); + saveBtn.addEventListener('click', async () => { + const profileId = profile.id; + const secret = secretInput.value.trim(); + if (!profileId) { + NexusTerminal.Notifications.show('Save the profile first, then set the TOTP secret.', 'warning'); + return; + } + if (!secret) { + // Delete existing secret + try { + await window.go.api.Service.DeleteTOTPSecret(profileId); + NexusTerminal.Notifications.show('TOTP secret removed.', 'info'); + } catch (err) { + NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error'); + } + return; + } + try { + await window.go.api.Service.SetTOTPSecret(profileId, secret); + NexusTerminal.Notifications.show('TOTP secret saved securely.', 'success'); + secretInput.value = ''; + secretInput.placeholder = '••••••••• (secret saved)'; + } catch (err) { + NexusTerminal.Notifications.show('Failed to save TOTP secret: ' + err, 'error'); + } + }); + + // Delete button + const deleteBtn = createElement('button', 'btn btn-small btn-danger totp-delete', { + textContent: 'Delete', + title: 'Remove the TOTP secret', + type: 'button', + }); + deleteBtn.addEventListener('mousedown', e => e.preventDefault()); + deleteBtn.addEventListener('click', async () => { + const profileId = profile.id; + if (!profileId) return; + try { + await window.go.api.Service.DeleteTOTPSecret(profileId); + NexusTerminal.Notifications.show('TOTP secret removed.', 'info'); + secretInput.placeholder = 'Base32-encoded secret (e.g. from authenticator app setup)'; + } catch (err) { + NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error'); + } + }); + + row.appendChild(secretInput); + row.appendChild(toggleBtn); + row.appendChild(testBtn); + row.appendChild(saveBtn); + row.appendChild(deleteBtn); + group.appendChild(row); + content.appendChild(group); + + // Check if secret is already saved and show placeholder + if (profile.id) { + (async () => { + try { + const has = await window.go.api.Service.HasTOTPSecret(profile.id); + if (has) { + secretInput.placeholder = '••••••••• (secret saved)'; + } + } catch (_) { + // Ignore — might not be unlocked. + } + })(); + } + + header.addEventListener('mousedown', (e) => e.preventDefault()); + header.addEventListener('click', () => { + collapsed = !collapsed; + content.style.display = collapsed ? 'none' : 'block'; + header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; + }); + + section.appendChild(header); + section.appendChild(content); + return section; + } + /** * Renders the login script section (expect-style send/wait steps). * @param {string} prefix - 'ssh' or 'telnet' diff --git a/go.mod b/go.mod index c4a2c79..45a705a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/alessio/shellescape v1.4.1 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect @@ -32,6 +33,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect diff --git a/go.sum b/go.sum index c3b25f8..7900eef 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,11 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -52,13 +55,17 @@ github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= diff --git a/internal/api/api.go b/internal/api/api.go index bec58e6..e9ac562 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/models/models.go b/internal/models/models.go index 9922fdf..3a07059 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index ba4b4d3..98d9c34 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -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 } diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index c243ae0..12896fd 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -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.