Files
winauth-go/internal/config/yaml.go
T
iceking2nd 0918fd446a feat(ui): 安全增强 — 修改密码 / 自动锁定 / HOTP 提示 / 配置备份
- 修改密码对话框引入旧密码校验:通过 store.VerifyPassword 走 constant-time
  比较,对话框自身不持有 live passphrase 副本。
- 配置加密且 auto_lock_minutes>0 时启用自动锁定;明文配置不受影响。
  锁定时刻意保留 store.passphrase,避免解锁瞬间出现保存竞态——进程内存
  无论如何都是威胁边界。
- HOTP 点击后 toast 仅显示推进后的计数器值,绝不携带验证码字符串。
- SaveYAML 每次写入前将旧文件轮转为 .bak(0o600),防止加密/序列化失败
  导致用户无可恢复副本。
- Preferences 对话框新增"自动锁定(分钟)"输入项。
2026-06-12 03:57:01 +08:00

133 lines
3.6 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"gopkg.in/yaml.v3"
"git.wxccs.org/iceking2nd/winauth-go/internal/crypto"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Sentinel errors returned by LoadYAML for the encrypted-config password
// path. Callers use errors.Is to distinguish them from generic I/O / parse
// failures.
var (
ErrPasswordRequired = errors.New("config: passphrase required")
ErrPasswordWrong = errors.New("config: wrong passphrase")
)
// SaveYAML writes the receiver as YAML to path. If passphrase is non-empty
// and cfg.Encrypted is true, the entries slice is serialized to JSON,
// encrypted, and stored as EncryptedBlob — entries are NOT written in
// plaintext in that case.
func SaveYAML(cfg *Config, path string, passphrase []byte) error {
const fn = "internal.config.SaveYAML"
logger := global.Log.WithField("func", fn).WithField("path", path)
out := *cfg
if cfg.Encrypted && len(passphrase) > 0 {
raw, err := json.Marshal(cfg.Entries)
if err != nil {
return err
}
blob, err := crypto.EncryptModern(raw, passphrase)
if err != nil {
return err
}
out.EncryptedBlob = blob
out.Entries = nil
}
data, err := yaml.Marshal(&out)
if err != nil {
return err
}
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
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
logger.Debug("config saved")
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) {
const fn = "internal.config.LoadYAML"
logger := global.Log.WithField("func", fn).WithField("path", path)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse YAML: %w", err)
}
if cfg.Encrypted && cfg.EncryptedBlob != "" {
if len(passphrase) == 0 {
return &cfg, ErrPasswordRequired
}
raw, err := crypto.DecryptModern(cfg.EncryptedBlob, passphrase)
if err != nil {
return &cfg, ErrPasswordWrong
}
if err := json.Unmarshal(raw, &cfg.Entries); err != nil {
return nil, fmt.Errorf("config: decode entries: %w", err)
}
}
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded")
return &cfg, nil
}