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:
+101
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user