diff --git a/internal/config/yaml.go b/internal/config/yaml.go index 78c3a5d..08c07a3 100644 --- a/internal/config/yaml.go +++ b/internal/config/yaml.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "gopkg.in/yaml.v3" @@ -49,6 +50,14 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error { if err := EnsureDir(path); err != nil { return err } + // Best-effort .bak rotation: if a previous file exists, copy it to + // path+".bak" before we overwrite, so a botched encrypt / serialize + // does not leave the user with no recoverable copy. Failure to back + // up is logged but does not block the save — the data is more + // important than the safety net. + if err := backupIfExists(path); err != nil { + logger.WithError(err).Warn("backup before save failed") + } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err @@ -61,6 +70,35 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error { return nil } +// backupIfExists copies path to path+".bak" with 0o600 permissions when +// path exists, atomically overwriting any previous .bak. Returns nil if +// path does not exist (first save). +func backupIfExists(path string) error { + src, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + defer src.Close() + bakTmp := path + ".bak.tmp" + dst, err := os.OpenFile(bakTmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + _ = os.Remove(bakTmp) + return err + } + if err := dst.Close(); err != nil { + _ = os.Remove(bakTmp) + return err + } + return os.Rename(bakTmp, path+".bak") +} + // LoadYAML reads the YAML config at path. If the file is encrypted, // passphrase is required and the EncryptedBlob is decrypted into Entries. func LoadYAML(path string, passphrase []byte) (*Config, error) { diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index 7558a17..5589d36 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -419,3 +419,32 @@ other = "MIT" [about_credits] other = "Original-WinAuth von Colin Mackie. Go-Portierung wird von den Projektautoren gepflegt." + +# --- Sicherheit: Passwort ändern / automatische Sperre / HOTP-Hinweis --- + +[menu_change_password] +other = "Passwort ändern..." + +[dialog_change_password_title] +other = "Passwort ändern" + +[label_password_current] +other = "Aktuelles Passwort" + +[label_password_new] +other = "Neues Passwort" + +[msg_password_changed] +other = "Passwort geändert." + +[msg_hotp_advanced] +other = "Zähler auf %d erhöht" + +[msg_locked] +other = "Wegen Inaktivität gesperrt. Passwort zum Entsperren eingeben." + +[label_auto_lock_minutes] +other = "Automatisch sperren nach (Minuten)" + +[hint_auto_lock_disabled] +other = "0 deaktiviert. Nur wirksam, wenn die Konfiguration verschlüsselt ist." diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml index b01168e..e7190d4 100644 --- a/internal/i18n/locales/en.toml +++ b/internal/i18n/locales/en.toml @@ -421,3 +421,32 @@ other = "MIT" [about_credits] other = "Original WinAuth by Colin Mackie. Go port maintained by the project authors." + +# --- Security: change password / auto-lock / HOTP toast --- + +[menu_change_password] +other = "Change password..." + +[dialog_change_password_title] +other = "Change password" + +[label_password_current] +other = "Current password" + +[label_password_new] +other = "New password" + +[msg_password_changed] +other = "Password changed." + +[msg_hotp_advanced] +other = "Counter advanced to %d" + +[msg_locked] +other = "Locked due to inactivity. Enter the password to unlock." + +[label_auto_lock_minutes] +other = "Auto-lock after (minutes)" + +[hint_auto_lock_disabled] +other = "Set to 0 to disable. Only takes effect when the configuration is encrypted." diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml index aadf2d1..195e76d 100644 --- a/internal/i18n/locales/zh-CN.toml +++ b/internal/i18n/locales/zh-CN.toml @@ -419,3 +419,32 @@ other = "MIT" [about_credits] other = "原版 WinAuth 由 Colin Mackie 创作。Go 移植由本项目作者维护。" + +# --- 安全:修改密码 / 自动锁定 / HOTP 提示 --- + +[menu_change_password] +other = "修改密码..." + +[dialog_change_password_title] +other = "修改密码" + +[label_password_current] +other = "当前密码" + +[label_password_new] +other = "新密码" + +[msg_password_changed] +other = "密码已更新。" + +[msg_hotp_advanced] +other = "计数器推进到 %d" + +[msg_locked] +other = "因长时间无操作已锁定,请输入密码解锁。" + +[label_auto_lock_minutes] +other = "自动锁定(分钟)" + +[hint_auto_lock_disabled] +other = "填 0 关闭。仅当配置已加密时生效。" diff --git a/internal/ui/app.go b/internal/ui/app.go index de9cd31..8c1e97a 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -89,6 +89,7 @@ type appState struct { dialog Dialog pwDialog *passwordDialog setPwDialog *setPasswordDialog + changePwDlg *changePasswordDialog importDialog *importLegacyDialog hotkeyDialog *hotkeyDialog hotkeyTarget *entry @@ -109,6 +110,15 @@ type appState struct { // theme is the cached material.Theme matching themeMode. nil forces a // rebuild on the next frame. theme *material.Theme + + // Auto-lock state. Active only when the config is encrypted and the + // user set auto_lock_minutes > 0. lastActivity is bumped on any UI + // interaction; once now-lastActivity exceeds the threshold the lock + // dialog is raised and the entry list hidden until the user + // re-enters the live passphrase. + lastActivity time.Time + locked bool + lockDialog *passwordDialog } // snapshotEntries returns a freshly serialized slice of config entries. @@ -128,6 +138,7 @@ func loop(w *app.Window, configPath string) error { state := &appState{} state.list.Axis = layout.Vertical + state.lastActivity = time.Now() state.store = newStore(configPath, state.snapshotEntries, func(err error) { state.mu.Lock() @@ -194,6 +205,42 @@ func loop(w *app.Window, configPath string) error { } } +// maybeAutoLock checks whether the inactivity threshold has elapsed and, +// if so, raises the lock dialog. Returns true when the UI should render +// the lock prompt this frame and skip the rest of the layout. The check +// is a no-op unless the configuration is encrypted AND the user set +// auto_lock_minutes > 0 — locking a plaintext config buys nothing +// security-wise (the file is already readable). +// +// Note: we do NOT clear store.passphrase on lock. Re-decrypt on every +// unlock would mean a transient window where we cannot save (e.g. a +// hotkey press just before unlock would race), and it would not improve +// the security model — process memory holding the key is the threat +// either way. +func (st *appState) maybeAutoLock() bool { + if st.locked { + return true + } + if !st.store.Encrypted() { + return false + } + _, _, autoLockMinutes, _ := st.store.Preferences() + if autoLockMinutes <= 0 { + return false + } + if st.lastActivity.IsZero() { + st.lastActivity = time.Now() + return false + } + threshold := time.Duration(autoLockMinutes) * time.Minute + if time.Since(st.lastActivity) < threshold { + return false + } + st.locked = true + st.lockDialog = newPasswordDialog(i18n.T("msg_locked")) + return true +} + // absorbConfig replaces the in-memory entries with the contents of cfg, // best-effort: bad entries are logged and skipped. func (st *appState) absorbConfig(cfg *config.Config) { @@ -235,8 +282,32 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) { } func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions { + // Auto-lock check runs before any input is dispatched so a user who + // returns mid-frame still has to type the passphrase before they can + // interact. Active only when the config is encrypted and the user + // configured a positive timeout. + if st.maybeAutoLock() && st.lockDialog != nil { + return st.lockDialog.Layout(gtx, th, func(pw []byte, ok bool) { + if !ok { + // Cancel is meaningless while locked — just re-prompt. + st.lockDialog.SetError(i18n.T("msg_locked")) + w.Invalidate() + return + } + if st.store.VerifyPassword(pw) { + st.locked = false + st.lockDialog = nil + st.lastActivity = time.Now() + } else { + st.lockDialog.SetError(i18n.T("msg_password_wrong")) + } + w.Invalidate() + }) + } + if st.addBtn.Clicked(gtx) { st.vendorMenu = newVendorMenu() + st.lastActivity = time.Now() } if st.removeBtn.Clicked(gtx) { st.mu.Lock() @@ -245,9 +316,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind } st.mu.Unlock() st.store.Push() + st.lastActivity = time.Now() } if st.settingsBtn.Clicked(gtx) { st.settingsMenu = newSettingsMenu() + st.lastActivity = time.Now() } // Refresh TOTP codes on every frame; HOTP entries advance on user click only. @@ -259,18 +332,27 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind if en.Auth.Name() == "steam" { if en.tradesBtn.Clicked(gtx) { tradesTarget = en + st.lastActivity = time.Now() } } if en.hotkeyBtn.Clicked(gtx) { hotkeyTarget = en + st.lastActivity = time.Now() } if en.copyBtn.Clicked(gtx) { copyTarget = en + st.lastActivity = time.Now() } if en.Auth.Name() == "hotp" { if en.click.Clicked(gtx) { if code, err := en.Auth.CurrentCode(); err == nil { en.Code = code + // Surface the new counter value (not the code) so the + // user has feedback that the click registered. + if h, ok := en.Auth.(*authenticator.HOTPAuthenticator); ok { + st.toast.Show(fmt.Sprintf(i18n.T("msg_hotp_advanced"), h.Counter), w) + } + st.lastActivity = time.Now() // Counter advanced — persist so a restart does not // reuse the same counter value. go st.store.Push() @@ -330,17 +412,32 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind }) } + if st.changePwDlg != nil { + return st.changePwDlg.Layout(gtx, th, + func(oldPw []byte) bool { return st.store.VerifyPassword(oldPw) }, + func(r changePasswordResult) { + if !r.cancel { + st.store.SetPassword(r.newPw) + st.toast.Show(i18n.T("msg_password_changed"), w) + } + st.changePwDlg = nil + w.Invalidate() + }) + } + if st.settingsMenu != nil { if act, closed := st.settingsMenu.Pick(gtx); closed { st.settingsMenu = nil switch act { case settingsActionSetPassword: st.setPwDialog = newSetPasswordDialog() + case settingsActionChangePassword: + st.changePwDlg = newChangePasswordDialog() case settingsActionImportLegacy: st.importDialog = newImportLegacyDialog() case settingsActionPreferences: - lang, theme, _, _ := st.store.Preferences() - st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme)) + lang, theme, autoLock, _ := st.store.Preferences() + st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock) case settingsActionAbout: st.aboutDialog = newAboutDialog() } @@ -353,8 +450,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind if st.prefsDialog != nil { return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) { if !r.cancel { - _, _, autoLock, minToTray := st.store.Preferences() - st.store.SetPreferences(r.language, string(r.theme), autoLock, minToTray) + _, _, _, minToTray := st.store.Preferences() + st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, minToTray) i18n.SetLanguage(r.language) st.themeMode = r.theme st.theme = nil // force rebuild on next frame diff --git a/internal/ui/dialog_change_password.go b/internal/ui/dialog_change_password.go new file mode 100644 index 0000000..7315928 --- /dev/null +++ b/internal/ui/dialog_change_password.go @@ -0,0 +1,88 @@ +package ui + +import ( + "crypto/subtle" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +// changePasswordDialog asks for the current passphrase, then a new +// passphrase + confirmation. The dialog refuses to submit unless the +// current passphrase matches what the store already holds (via a +// constant-time compare to avoid leaking length / prefix information). +type changePasswordDialog struct { + oldEd widget.Editor + newEd widget.Editor + confirmEd widget.Editor + okBtn widget.Clickable + cancelBtn widget.Clickable + errorMsg string +} + +func newChangePasswordDialog() *changePasswordDialog { + d := &changePasswordDialog{} + d.oldEd.SingleLine = true + d.oldEd.Mask = '*' + d.newEd.SingleLine = true + d.newEd.Mask = '*' + d.confirmEd.SingleLine = true + d.confirmEd.Mask = '*' + return d +} + +// changePasswordResult tells the caller what to do. cancel skips any +// state change. ok=true means commit newPw via store.SetPassword. +type changePasswordResult struct { + cancel bool + newPw []byte +} + +// Layout takes a verify callback that returns true when oldPw matches +// what the store believes the current passphrase is. The caller is +// responsible for that comparison so the dialog itself never has to +// hold a copy of the live passphrase. +func (d *changePasswordDialog) Layout( + gtx layout.Context, th *material.Theme, + verifyOld func(oldPw []byte) bool, + onDone func(changePasswordResult), +) layout.Dimensions { + if d.cancelBtn.Clicked(gtx) { + onDone(changePasswordResult{cancel: true}) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + if d.okBtn.Clicked(gtx) { + oldPw := []byte(d.oldEd.Text()) + newPw := []byte(d.newEd.Text()) + confirm := []byte(d.confirmEd.Text()) + switch { + case !verifyOld(oldPw): + // Use the constant-length wrong-password string so the + // presence of an error is not itself a side channel. + d.errorMsg = i18n.T("msg_password_wrong") + case subtle.ConstantTimeCompare(newPw, confirm) != 1: + d.errorMsg = i18n.T("msg_password_mismatch") + default: + onDone(changePasswordResult{newPw: newPw}) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + } + + body := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditor(th, i18n.T("label_password_current"), &d.oldEd, "")), + layout.Rigid(labeledEditor(th, i18n.T("label_password_new"), &d.newEd, "")), + layout.Rigid(labeledEditor(th, i18n.T("label_password_confirm"), &d.confirmEd, "")), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(material.Caption(th, i18n.T("hint_password_empty_disables")).Layout), + layout.Rigid(errorLabel(th, d.errorMsg)), + ) + } + return modalCard(gtx, th, i18n.T("dialog_change_password_title"), + i18n.T("btn_ok"), i18n.T("btn_cancel"), + &d.okBtn, &d.cancelBtn, body) +} diff --git a/internal/ui/dialog_preferences.go b/internal/ui/dialog_preferences.go index db6fb21..ca2ab71 100644 --- a/internal/ui/dialog_preferences.go +++ b/internal/ui/dialog_preferences.go @@ -1,6 +1,9 @@ package ui import ( + "strconv" + "strings" + "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" @@ -9,8 +12,8 @@ import ( "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" ) -// preferencesDialog edits the persistent UI prefs: UI language and -// theme. Auto-lock and minimize-to-tray live in their own bucket and +// preferencesDialog edits the persistent UI prefs: UI language, theme, +// and auto-lock timeout. Minimize-to-tray lives in its own bucket and // will appear here later. type preferencesDialog struct { lang string @@ -27,6 +30,11 @@ type preferencesDialog struct { langZhCNBt widget.Clickable langDeBt widget.Clickable + // Auto-lock timeout (whole minutes). 0 disables. Only takes effect + // when the config is encrypted — the prefs dialog does not gate the + // input on that, the store does. + autoLockEd widget.Editor + okBt widget.Clickable cancelBt widget.Clickable } @@ -34,9 +42,10 @@ type preferencesDialog struct { // preferencesResult is what Layout's callback receives. cancel skips // any persistence. type preferencesResult struct { - cancel bool - language string - theme themeMode + cancel bool + language string + theme themeMode + autoLockMinutes int } // supportedLanguages is the closed set the prefs dialog exposes. Keep @@ -50,14 +59,17 @@ var supportedLanguages = []struct { {"de", "Deutsch"}, } -func newPreferencesDialog(currentLang string, currentTheme themeMode) *preferencesDialog { +func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int) *preferencesDialog { if currentLang == "" { currentLang = "en" } if currentTheme == "" { currentTheme = themeSystem } - return &preferencesDialog{lang: currentLang, theme: currentTheme} + d := &preferencesDialog{lang: currentLang, theme: currentTheme} + d.autoLockEd.SingleLine = true + d.autoLockEd.SetText(strconv.Itoa(currentAutoLock)) + return d } func (d *preferencesDialog) Layout( @@ -69,7 +81,14 @@ func (d *preferencesDialog) Layout( return layout.Dimensions{Size: gtx.Constraints.Max} } if d.okBt.Clicked(gtx) { - onDone(preferencesResult{language: d.lang, theme: d.theme}) + // Best-effort parse — non-numeric or negative input maps to 0 + // (disabled) rather than surfacing an error, since the field + // is paired with a "0 disables" hint. + mins, err := strconv.Atoi(strings.TrimSpace(d.autoLockEd.Text())) + if err != nil || mins < 0 { + mins = 0 + } + onDone(preferencesResult{language: d.lang, theme: d.theme, autoLockMinutes: mins}) return layout.Dimensions{Size: gtx.Constraints.Max} } @@ -127,6 +146,10 @@ func (d *preferencesDialog) Layout( chip(&d.langDeBt, supportedLanguages[2].label, d.lang == "de"), ) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(labeledEditor(th, i18n.T("label_auto_lock_minutes"), &d.autoLockEd, "")), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(material.Caption(th, i18n.T("hint_auto_lock_disabled")).Layout), ) } diff --git a/internal/ui/settings_menu.go b/internal/ui/settings_menu.go index 1d225c0..a92afd6 100644 --- a/internal/ui/settings_menu.go +++ b/internal/ui/settings_menu.go @@ -15,6 +15,7 @@ type settingsAction int const ( settingsActionNone settingsAction = iota settingsActionSetPassword + settingsActionChangePassword settingsActionImportLegacy settingsActionPreferences settingsActionAbout @@ -23,11 +24,12 @@ const ( // settingsMenu is the small popup that opens when the user clicks the // gear button in the main window's top bar. type settingsMenu struct { - setPwBtn widget.Clickable - importBtn widget.Clickable - prefsBtn widget.Clickable - aboutBtn widget.Clickable - cancelBtn widget.Clickable + setPwBtn widget.Clickable + changePwBtn widget.Clickable + importBtn widget.Clickable + prefsBtn widget.Clickable + aboutBtn widget.Clickable + cancelBtn widget.Clickable } func newSettingsMenu() *settingsMenu { return &settingsMenu{} } @@ -37,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) { switch { case m.setPwBtn.Clicked(gtx): return settingsActionSetPassword, true + case m.changePwBtn.Clicked(gtx): + return settingsActionChangePassword, true case m.importBtn.Clicked(gtx): return settingsActionImportLegacy, true case m.prefsBtn.Clicked(gtx): @@ -75,6 +79,7 @@ func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dim layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), row(&m.setPwBtn, i18n.T("menu_set_password")), + row(&m.changePwBtn, i18n.T("menu_change_password")), row(&m.importBtn, i18n.T("menu_import_legacy")), row(&m.prefsBtn, i18n.T("menu_preferences")), row(&m.aboutBtn, i18n.T("menu_about")), diff --git a/internal/ui/store.go b/internal/ui/store.go index b391ad6..1e1ce8b 100644 --- a/internal/ui/store.go +++ b/internal/ui/store.go @@ -1,6 +1,7 @@ package ui import ( + "crypto/subtle" "errors" "os" "sync" @@ -141,6 +142,17 @@ func (s *store) SetPassword(pw []byte) { s.Push() } +// VerifyPassword reports whether candidate matches the currently held +// passphrase. Uses subtle.ConstantTimeCompare so equal-length wrong +// guesses do not leak via timing. Holding a copy of the passphrase +// outside the store would expand its blast radius, so callers always +// go through this method. +func (s *store) VerifyPassword(candidate []byte) bool { + s.mu.Lock() + defer s.mu.Unlock() + return subtle.ConstantTimeCompare(candidate, s.passphrase) == 1 +} + // Encrypted reports whether the store will encrypt the next write. func (s *store) Encrypted() bool { s.mu.Lock()