feat(ui): 安全增强 — 修改密码 / 自动锁定 / HOTP 提示 / 配置备份

- 修改密码对话框引入旧密码校验:通过 store.VerifyPassword 走 constant-time
  比较,对话框自身不持有 live passphrase 副本。
- 配置加密且 auto_lock_minutes>0 时启用自动锁定;明文配置不受影响。
  锁定时刻意保留 store.passphrase,避免解锁瞬间出现保存竞态——进程内存
  无论如何都是威胁边界。
- HOTP 点击后 toast 仅显示推进后的计数器值,绝不携带验证码字符串。
- SaveYAML 每次写入前将旧文件轮转为 .bak(0o600),防止加密/序列化失败
  导致用户无可恢复副本。
- Preferences 对话框新增"自动锁定(分钟)"输入项。
This commit is contained in:
2026-06-12 03:56:41 +08:00
parent fedc198452
commit 0918fd446a
9 changed files with 367 additions and 17 deletions
+38
View File
@@ -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) {
+29
View File
@@ -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."
+29
View File
@@ -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."
+29
View File
@@ -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 关闭。仅当配置已加密时生效。"
+101 -4
View File
@@ -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
+88
View File
@@ -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)
}
+31 -8
View File
@@ -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),
)
}
+10 -5
View File
@@ -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")),
+12
View File
@@ -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()