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
+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