diff --git a/go.mod b/go.mod index b4ce7af..97bf142 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/knadh/go-pop3 v1.0.2 github.com/mattn/go-sixel v0.0.9 + github.com/wagslane/go-password-validator v0.3.0 github.com/yuin/goldmark v1.8.2 github.com/yuin/gopher-lua v1.1.2 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index eeae001..e4373b5 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index ac7ac44..d681878 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -174,6 +174,12 @@ "disable_confirm": "تعطيل التشفير؟", "disable_warning": "سيتم تخزين جميع البيانات بدون تشفير.", "encrypting": "جاري تشفير البيانات...", + "passwords_match": "✓ كلمات المرور متطابقة", + "passwords_do_not_match": "✗ كلمات المرور غير متطابقة", + "strength_label": "القوة:", + "strength_weak": "ضعيف", + "strength_medium": "متوسط", + "strength_strong": "قوي", "error_empty": "لا يمكن أن تكون كلمة المرور فارغة", "error_mismatch": "كلمات المرور غير متطابقة", "help": "tab: التالي • enter: حفظ" diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 6a96076..8fc87d3 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -170,6 +170,12 @@ "disable_confirm": "Verschlüsselung deaktivieren?", "disable_warning": "Alle Daten werden unverschlüsselt gespeichert.", "encrypting": "Daten werden verschlüsselt...", + "passwords_match": "✓ Passwörter stimmen überein", + "passwords_do_not_match": "✗ Passwörter stimmen nicht überein", + "strength_label": "Stärke:", + "strength_weak": "schwach", + "strength_medium": "mittel", + "strength_strong": "stark", "error_empty": "Passwort darf nicht leer sein", "error_mismatch": "Passwörter stimmen nicht überein", "help": "tab: nächstes • enter: speichern" diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4fcf69b..8d0c465 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -170,6 +170,12 @@ "disable_confirm": "Disable encryption?", "disable_warning": "All data will be stored unencrypted.", "encrypting": "Encrypting data...", + "passwords_match": "✓ Passwords match", + "passwords_do_not_match": "✗ Passwords do not match", + "strength_label": "Strength:", + "strength_weak": "weak", + "strength_medium": "medium", + "strength_strong": "strong", "error_empty": "Password cannot be empty", "error_mismatch": "Passwords do not match", "help": "tab: next • enter: save" diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 88a07ef..1bc563a 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -170,6 +170,12 @@ "disable_confirm": "¿Deshabilitar cifrado?", "disable_warning": "Todos los datos se almacenarán sin cifrar.", "encrypting": "Cifrando datos...", + "passwords_match": "✓ Las contraseñas coinciden", + "passwords_do_not_match": "✗ Las contraseñas no coinciden", + "strength_label": "Fortaleza:", + "strength_weak": "débil", + "strength_medium": "media", + "strength_strong": "fuerte", "error_empty": "La contraseña no puede estar vacía", "error_mismatch": "Las contraseñas no coinciden", "help": "tab: siguiente • enter: guardar" diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 6ad0a8f..62220e5 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -170,6 +170,12 @@ "disable_confirm": "Désactiver le chiffrement ?", "disable_warning": "Toutes les données seront stockées non chiffrées.", "encrypting": "Chiffrement des données...", + "passwords_match": "✓ Les mots de passe correspondent", + "passwords_do_not_match": "✗ Les mots de passe ne correspondent pas", + "strength_label": "Force :", + "strength_weak": "faible", + "strength_medium": "moyen", + "strength_strong": "fort", "error_empty": "Le mot de passe ne peut pas être vide", "error_mismatch": "Les mots de passe ne correspondent pas", "help": "tab: suivant • entrée: enregistrer" diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 9556af9..f5892f7 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -168,6 +168,12 @@ "disable_confirm": "暗号化を無効にしますか?", "disable_warning": "すべてのデータは暗号化されずに保存されます。", "encrypting": "データを暗号化中...", + "passwords_match": "✓ パスワードが一致しました", + "passwords_do_not_match": "✗ パスワードが一致しません", + "strength_label": "強度:", + "strength_weak": "弱い", + "strength_medium": "普通", + "strength_strong": "強い", "error_empty": "パスワードを空にすることはできません", "error_mismatch": "パスワードが一致しません", "help": "tab: 次へ • enter: 保存" diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index d4ea5a6..2c8a82a 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -174,6 +174,12 @@ "disable_confirm": "Wyłączyć szyfrowanie?", "disable_warning": "Wszystkie dane będą przechowywane bez szyfrowania.", "encrypting": "Szyfrowanie danych...", + "passwords_match": "✓ Hasła pasują do siebie", + "passwords_do_not_match": "✗ Hasła nie pasują do siebie", + "strength_label": "Siła:", + "strength_weak": "słabe", + "strength_medium": "średnie", + "strength_strong": "silne", "error_empty": "Hasło nie może być puste", "error_mismatch": "Hasła nie pasują do siebie", "help": "tab: następny • enter: zapisz" diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index a9a8663..3d65627 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -170,6 +170,12 @@ "disable_confirm": "Desativar criptografia?", "disable_warning": "Todos os dados serão armazenados sem criptografia.", "encrypting": "Criptografando dados...", + "passwords_match": "✓ As senhas coincidem", + "passwords_do_not_match": "✗ As senhas não coincidem", + "strength_label": "Força:", + "strength_weak": "fraca", + "strength_medium": "média", + "strength_strong": "forte", "error_empty": "A senha não pode estar vazia", "error_mismatch": "As senhas não coincidem", "help": "tab: próximo • enter: salvar" diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index ddaf0e0..f18e8c6 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -174,6 +174,12 @@ "disable_confirm": "Отключить шифрование?", "disable_warning": "Все данные будут храниться незашифрованными.", "encrypting": "Шифрование данных...", + "passwords_match": "✓ Пароли совпадают", + "passwords_do_not_match": "✗ Пароли не совпадают", + "strength_label": "Надёжность:", + "strength_weak": "слабый", + "strength_medium": "средний", + "strength_strong": "сильный", "error_empty": "Пароль не может быть пустым", "error_mismatch": "Пароли не совпадают", "help": "tab: следующий • enter: сохранить" diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index cf6c1f4..37a597e 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -172,6 +172,12 @@ "disable_confirm": "Вимкнути шифрування?", "disable_warning": "Всі дані будуть зберігатися без шифрування.", "encrypting": "Шифрування даних...", + "passwords_match": "✓ Паролі співпадають", + "passwords_do_not_match": "✗ Паролі не співпадають", + "strength_label": "Надійність:", + "strength_weak": "слабкий", + "strength_medium": "середній", + "strength_strong": "сильний", "error_empty": "Пароль не може бути порожнім", "error_mismatch": "Паролі не співпадають", "help": "tab: далі • enter: зберегти" diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 730d46f..276af2f 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -168,6 +168,12 @@ "disable_confirm": "禁用加密?", "disable_warning": "所有数据将以未加密方式存储。", "encrypting": "正在加密数据...", + "passwords_match": "✓ 密码匹配", + "passwords_do_not_match": "✗ 密码不匹配", + "strength_label": "强度:", + "strength_weak": "弱", + "strength_medium": "中等", + "strength_strong": "强", "error_empty": "密码不能为空", "error_mismatch": "密码不匹配", "help": "tab: 下一项 • enter: 保存" diff --git a/internal/passwordstrength/lib_meter.go b/internal/passwordstrength/lib_meter.go new file mode 100644 index 0000000..141504c --- /dev/null +++ b/internal/passwordstrength/lib_meter.go @@ -0,0 +1,21 @@ +package passwordstrength + +import passwordvalidator "github.com/wagslane/go-password-validator" + +type LibMeter struct{} + +func NewLibMeter() LibMeter { + return LibMeter{} +} + +func (m LibMeter) Strength(password string) Strength { + entropy := passwordvalidator.GetEntropy(password) + switch { + case entropy >= strongEntropyBits: + return Strong + case entropy >= mediumEntropyBits: + return Medium + default: + return Weak + } +} diff --git a/internal/passwordstrength/meter.go b/internal/passwordstrength/meter.go new file mode 100644 index 0000000..994ac3d --- /dev/null +++ b/internal/passwordstrength/meter.go @@ -0,0 +1,18 @@ +package passwordstrength + +type Strength string + +const ( + Weak Strength = "weak" + Medium Strength = "medium" + Strong Strength = "strong" +) + +const ( + mediumEntropyBits = 50 + strongEntropyBits = 70 +) + +type Meter interface { + Strength(password string) Strength +} diff --git a/tui/settings.go b/tui/settings.go index f0d4e8c..affa34a 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/internal/passwordstrength" "github.com/floatpane/matcha/plugin" "github.com/floatpane/matcha/theme" ) @@ -16,6 +17,7 @@ var ( selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")).Bold(true) accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) @@ -69,12 +71,14 @@ type Settings struct { pgpPINInput textinput.Model // Encryption fields - encPasswordInput textinput.Model - encConfirmInput textinput.Model - encFocusIndex int - encError string - encEnabling bool - confirmingDisable bool + encPasswordInput textinput.Model + encConfirmInput textinput.Model + encFocusIndex int + encError string + encEnabling bool + confirmingDisable bool + passwordMeter passwordstrength.Meter + encPasswordStrength passwordstrength.Strength // Plugin settings state plugins *plugin.Manager @@ -130,6 +134,7 @@ func NewSettings(cfg *config.Config) *Settings { pgpKeySource: "file", encPasswordInput: newInput("Password", "> ", true), encConfirmInput: newInput("Confirm Password", "> ", true), + passwordMeter: passwordstrength.NewLibMeter(), pluginInput: newInput("", "> ", false), } } @@ -292,6 +297,7 @@ func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.encError = "" m.encPasswordInput.SetValue("") m.encConfirmInput.SetValue("") + m.encPasswordStrength = "" m.encFocusIndex = 0 m.confirmingDisable = false m.encEnabling = false diff --git a/tui/settings_encryption.go b/tui/settings_encryption.go index 63e1e51..3d0a041 100644 --- a/tui/settings_encryption.go +++ b/tui/settings_encryption.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/internal/passwordstrength" ) func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { @@ -38,6 +39,7 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Clear inputs and return to menu m.encPasswordInput.SetValue("") m.encConfirmInput.SetValue("") + m.encPasswordStrength = "" m.encPasswordInput.Blur() m.encConfirmInput.Blur() m.encError = "" @@ -98,7 +100,11 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Forward input to focused textinput var cmd tea.Cmd if m.encFocusIndex == 0 { + before := m.encPasswordInput.Value() m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) + if m.encPasswordInput.Value() != before { + m.handlePasswordChanged() + } } else if m.encFocusIndex == 1 { m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) } @@ -137,13 +143,20 @@ func (m *Settings) viewEncryption() string { b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.password_label") + "\n")) } b.WriteString(m.encPasswordInput.View() + "\n\n") + if m.encPasswordStrength != "" { + b.WriteString(" " + m.renderPasswordStrength() + "\n\n") + } if m.encFocusIndex == 1 { b.WriteString(settingsFocusedStyle.Render(t("settings_encryption.confirm_label") + "\n")) } else { b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.confirm_label") + "\n")) } - b.WriteString(m.encConfirmInput.View() + "\n\n") + b.WriteString(m.encConfirmInput.View() + "\n") + if status := m.renderPasswordMatch(); status != "" { + b.WriteString(" " + status + "\n") + } + b.WriteString("\n") saveBtn := "[ " + t("settings_encryption.enable_button") + " ]" if m.encFocusIndex == 2 { @@ -165,3 +178,35 @@ func (m *Settings) viewEncryption() string { return b.String() } + +func (m *Settings) renderPasswordMatch() string { + password := m.encPasswordInput.Value() + confirm := m.encConfirmInput.Value() + if confirm == "" { + return "" + } + if password == confirm { + return successStyle.Render(t("settings_encryption.passwords_match")) + } + return dangerStyle.Render(t("settings_encryption.passwords_do_not_match")) +} + +func (m *Settings) handlePasswordChanged() { + password := m.encPasswordInput.Value() + if password == "" { + m.encPasswordStrength = "" + return + } + m.encPasswordStrength = m.passwordMeter.Strength(password) +} + +func (m *Settings) renderPasswordStrength() string { + switch m.encPasswordStrength { + case passwordstrength.Strong: + return successStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_strong")) + case passwordstrength.Medium: + return settingsFocusedStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_medium")) + default: + return dangerStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_weak")) + } +}