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) {