From 94f2142d3d958fc737eeea43a655e5f959c470f9 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Thu, 25 Jun 2026 13:38:59 +0700 Subject: [PATCH] feat: encryption password prompt + --encrypt flag - PasswordPromptTab: setup/unlock modes, password + confirm fields - --encrypt flag: shows password prompt on TUI startup - Model.ShowEncryptPrompt() triggers prompt on first render - passwordSetMsg stores password in Model.storagePassword - Tab interface: added Close() method to PasswordPromptTab --- cmd/hostkeeper/tui.go | 8 ++ pkg/tui/messages.go | 3 + pkg/tui/password_prompt_tab.go | 218 +++++++++++++++++++++++++++++++++ pkg/tui/tui.go | 33 ++++- 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 pkg/tui/password_prompt_tab.go diff --git a/cmd/hostkeeper/tui.go b/cmd/hostkeeper/tui.go index a060227..6e20ac1 100644 --- a/cmd/hostkeeper/tui.go +++ b/cmd/hostkeeper/tui.go @@ -13,6 +13,8 @@ import ( "git.tukangketik.id/swanadiva/hostkeeper/pkg/tui" ) +var encryptFlag bool + // tuiCmd represents the tui command var tuiCmd = &cobra.Command{ Use: "tui", @@ -23,6 +25,7 @@ var tuiCmd = &cobra.Command{ func init() { rootCmd.AddCommand(tuiCmd) + tuiCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "Enable AES-256-GCM encryption for sensitive data") } func runTUI(cmd *cobra.Command, args []string) error { @@ -59,6 +62,11 @@ func runTUI(cmd *cobra.Command, args []string) error { model.SetKnownHosts(kh) } + // If --encrypt flag, show password prompt first + if encryptFlag { + model.ShowEncryptPrompt() + } + p := tea.NewProgram(model) model.SetProgram(p) if _, err := p.Run(); err != nil { diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go index 4acd9e4..320f554 100644 --- a/pkg/tui/messages.go +++ b/pkg/tui/messages.go @@ -178,3 +178,6 @@ func saveHostCmd(host *models.Host, dataDir string) tea.Cmd { return saveHostResultMsg{host: host} } } + +// openEncryptPromptMsg shows the encryption setup prompt +type openEncryptPromptMsg struct{} diff --git a/pkg/tui/password_prompt_tab.go b/pkg/tui/password_prompt_tab.go new file mode 100644 index 0000000..a4422bc --- /dev/null +++ b/pkg/tui/password_prompt_tab.go @@ -0,0 +1,218 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// passwordPromptMode determines what the password prompt is for +type passwordPromptMode int + +const ( + // passwordModeSetup — first time enabling encryption, ask for new password + passwordModeSetup passwordPromptMode = iota + // passwordModeUnlock — encryption already enabled, ask for existing password + passwordModeUnlock +) + +// PasswordPromptTab shows a password prompt for encryption setup or unlock +type PasswordPromptTab struct { + mode passwordPromptMode + inputs []textinput.Model + focus int + width int + height int + err error + dataDir string + onComplete func(password string) // called with password on success +} + +// NewPasswordPromptTab creates a new password prompt tab +func NewPasswordPromptTab(mode passwordPromptMode, dataDir string, onComplete func(string)) *PasswordPromptTab { + p := &PasswordPromptTab{ + mode: mode, + dataDir: dataDir, + onComplete: onComplete, + } + + // Password field + passwordInput := textinput.New() + passwordInput.Placeholder = "Enter master password" + passwordInput.EchoMode = textinput.EchoPassword + passwordInput.EchoCharacter = '•' + passwordInput.Focus() + + // Confirm password field (only for setup mode) + confirmInput := textinput.New() + confirmInput.Placeholder = "Confirm password" + confirmInput.EchoMode = textinput.EchoPassword + confirmInput.EchoCharacter = '•' + + if mode == passwordModeSetup { + p.inputs = []textinput.Model{passwordInput, confirmInput} + } else { + p.inputs = []textinput.Model{passwordInput} + } + + return p +} + +func (p *PasswordPromptTab) Name() string { + if p.mode == passwordModeSetup { + return "Setup Encryption" + } + return "Unlock Storage" +} + +func (p *PasswordPromptTab) Init() tea.Cmd { + return textinput.Blink +} + +func (p *PasswordPromptTab) Update(msg tea.Msg) (Tab, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.width = msg.Width + p.height = msg.Height + return p, nil + + case tea.KeyMsg: + switch msg.String() { + case "tab", "down": + p.focus++ + if p.focus >= len(p.inputs) { + p.focus = 0 + } + for i := range p.inputs { + if i == p.focus { + p.inputs[i].Focus() + } else { + p.inputs[i].Blur() + } + } + return p, nil + + case "shift+tab", "up": + p.focus-- + if p.focus < 0 { + p.focus = len(p.inputs) - 1 + } + for i := range p.inputs { + if i == p.focus { + p.inputs[i].Focus() + } else { + p.inputs[i].Blur() + } + } + return p, nil + + case "enter": + password := p.inputs[0].Value() + if password == "" { + p.err = fmt.Errorf("password cannot be empty") + return p, nil + } + + if p.mode == passwordModeSetup && len(p.inputs) > 1 { + confirm := p.inputs[1].Value() + if password != confirm { + p.err = fmt.Errorf("passwords do not match") + return p, nil + } + } + + // Success — call onComplete + if p.onComplete != nil { + p.onComplete(password) + } + return p, func() tea.Msg { return passwordSetMsg{} } + + case "esc": + // Cancel — go back or quit + return p, func() tea.Msg { return closeFormMsg{} } + + default: + // Update current input + var cmd tea.Cmd + p.inputs[p.focus], cmd = p.inputs[p.focus].Update(msg) + return p, cmd + } + } + + return p, nil +} + +func (p *PasswordPromptTab) Close() { + // Nothing to clean up +} + +func (p *PasswordPromptTab) View() string { + var b strings.Builder + + title := "Setup Master Password" + if p.mode == passwordModeUnlock { + title = "Enter Master Password" + } + + b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, + AppTitleStyle.Render(title))) + b.WriteString("\n\n") + + if p.mode == passwordModeSetup { + b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, + SubtitleStyle.Render("Encrypt all sensitive data (passwords, keys) with AES-256"))) + b.WriteString("\n") + b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, + SubtitleStyle.Render("You will need this password to access your data"))) + b.WriteString("\n\n") + } + + // Input fields + contentW := p.width - 8 + if contentW > 60 { + contentW = 60 + } + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2). + Width(contentW) + + var fields strings.Builder + for i, input := range p.inputs { + label := "Password:" + if i == 1 { + label = "Confirm:" + } + fields.WriteString(SubtitleStyle.Render(" " + label)) + fields.WriteString("\n") + fields.WriteString(input.View()) + fields.WriteString("\n\n") + } + + b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, box.Render(fields.String()))) + + if p.err != nil { + b.WriteString("\n") + b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, + ErrorStyle.Render(p.err.Error()))) + } + + // Footer + footerText := "Enter:confirm Esc:cancel Tab:next field" + b.WriteString("\n\n") + for _, line := range strings.Split(wrapFooter(footerText, p.width), "\n") { + b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, SubtitleStyle.Render(line))) + } + + return b.String() +} + +// passwordSetMsg is sent when password is successfully set +type passwordSetMsg struct { + password string +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 99c9200..ef70d10 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -31,8 +31,9 @@ type Model struct { program *tea.Program // Security - storagePassword string - knownHosts *knownhosts.KnownHosts + storagePassword string + knownHosts *knownhosts.KnownHosts + showEncryptPrompt bool } // New creates a new TUI model @@ -50,6 +51,15 @@ func New() *Model { // Init initializes the TUI func (m *Model) Init() tea.Cmd { + if m.showEncryptPrompt { + // Show password prompt tab + tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) { + m.storagePassword = password + }) + cmd := m.tabs.Add(tab) + m.showEncryptPrompt = false + return cmd + } return m.tabs.Init() } @@ -68,6 +78,11 @@ func (m *Model) SetKnownHosts(kh *knownhosts.KnownHosts) { m.knownHosts = kh } +// ShowEncryptPrompt sets a flag to show the encryption setup prompt on first render +func (m *Model) ShowEncryptPrompt() { + m.showEncryptPrompt = true +} + // Update handles messages and updates the model func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -101,6 +116,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.tabs.Add(tab) return m, cmd + case passwordSetMsg: + // Password was set — store it and proceed to host list + m.storagePassword = msg.password + // Close the password prompt tab, host list should already be there + return m, nil + + case openEncryptPromptMsg: + // Show password setup prompt + tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) { + m.storagePassword = password + }) + cmd := m.tabs.Add(tab) + return m, cmd + case openSFTPMsg: tab := NewSFTPBrowserTab(msg.host, m.dataDir) if m.program != nil {