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:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -49,6 +50,14 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
|
|||||||
if err := EnsureDir(path); err != nil {
|
if err := EnsureDir(path); err != nil {
|
||||||
return err
|
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"
|
tmp := path + ".tmp"
|
||||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -61,6 +70,35 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
|
|||||||
return nil
|
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,
|
// LoadYAML reads the YAML config at path. If the file is encrypted,
|
||||||
// passphrase is required and the EncryptedBlob is decrypted into Entries.
|
// passphrase is required and the EncryptedBlob is decrypted into Entries.
|
||||||
func LoadYAML(path string, passphrase []byte) (*Config, error) {
|
func LoadYAML(path string, passphrase []byte) (*Config, error) {
|
||||||
|
|||||||
@@ -419,3 +419,32 @@ other = "MIT"
|
|||||||
|
|
||||||
[about_credits]
|
[about_credits]
|
||||||
other = "Original-WinAuth von Colin Mackie. Go-Portierung wird von den Projektautoren gepflegt."
|
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."
|
||||||
|
|||||||
@@ -421,3 +421,32 @@ other = "MIT"
|
|||||||
|
|
||||||
[about_credits]
|
[about_credits]
|
||||||
other = "Original WinAuth by Colin Mackie. Go port maintained by the project authors."
|
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."
|
||||||
|
|||||||
@@ -419,3 +419,32 @@ other = "MIT"
|
|||||||
|
|
||||||
[about_credits]
|
[about_credits]
|
||||||
other = "原版 WinAuth 由 Colin Mackie 创作。Go 移植由本项目作者维护。"
|
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
@@ -89,6 +89,7 @@ type appState struct {
|
|||||||
dialog Dialog
|
dialog Dialog
|
||||||
pwDialog *passwordDialog
|
pwDialog *passwordDialog
|
||||||
setPwDialog *setPasswordDialog
|
setPwDialog *setPasswordDialog
|
||||||
|
changePwDlg *changePasswordDialog
|
||||||
importDialog *importLegacyDialog
|
importDialog *importLegacyDialog
|
||||||
hotkeyDialog *hotkeyDialog
|
hotkeyDialog *hotkeyDialog
|
||||||
hotkeyTarget *entry
|
hotkeyTarget *entry
|
||||||
@@ -109,6 +110,15 @@ type appState struct {
|
|||||||
// theme is the cached material.Theme matching themeMode. nil forces a
|
// theme is the cached material.Theme matching themeMode. nil forces a
|
||||||
// rebuild on the next frame.
|
// rebuild on the next frame.
|
||||||
theme *material.Theme
|
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.
|
// snapshotEntries returns a freshly serialized slice of config entries.
|
||||||
@@ -128,6 +138,7 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
|
|
||||||
state := &appState{}
|
state := &appState{}
|
||||||
state.list.Axis = layout.Vertical
|
state.list.Axis = layout.Vertical
|
||||||
|
state.lastActivity = time.Now()
|
||||||
|
|
||||||
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
|
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
|
||||||
state.mu.Lock()
|
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,
|
// absorbConfig replaces the in-memory entries with the contents of cfg,
|
||||||
// best-effort: bad entries are logged and skipped.
|
// best-effort: bad entries are logged and skipped.
|
||||||
func (st *appState) absorbConfig(cfg *config.Config) {
|
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 {
|
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) {
|
if st.addBtn.Clicked(gtx) {
|
||||||
st.vendorMenu = newVendorMenu()
|
st.vendorMenu = newVendorMenu()
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if st.removeBtn.Clicked(gtx) {
|
if st.removeBtn.Clicked(gtx) {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
@@ -245,9 +316,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
}
|
}
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
st.store.Push()
|
st.store.Push()
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if st.settingsBtn.Clicked(gtx) {
|
if st.settingsBtn.Clicked(gtx) {
|
||||||
st.settingsMenu = newSettingsMenu()
|
st.settingsMenu = newSettingsMenu()
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh TOTP codes on every frame; HOTP entries advance on user click only.
|
// 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.Auth.Name() == "steam" {
|
||||||
if en.tradesBtn.Clicked(gtx) {
|
if en.tradesBtn.Clicked(gtx) {
|
||||||
tradesTarget = en
|
tradesTarget = en
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if en.hotkeyBtn.Clicked(gtx) {
|
if en.hotkeyBtn.Clicked(gtx) {
|
||||||
hotkeyTarget = en
|
hotkeyTarget = en
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if en.copyBtn.Clicked(gtx) {
|
if en.copyBtn.Clicked(gtx) {
|
||||||
copyTarget = en
|
copyTarget = en
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if en.Auth.Name() == "hotp" {
|
if en.Auth.Name() == "hotp" {
|
||||||
if en.click.Clicked(gtx) {
|
if en.click.Clicked(gtx) {
|
||||||
if code, err := en.Auth.CurrentCode(); err == nil {
|
if code, err := en.Auth.CurrentCode(); err == nil {
|
||||||
en.Code = code
|
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
|
// Counter advanced — persist so a restart does not
|
||||||
// reuse the same counter value.
|
// reuse the same counter value.
|
||||||
go st.store.Push()
|
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 st.settingsMenu != nil {
|
||||||
if act, closed := st.settingsMenu.Pick(gtx); closed {
|
if act, closed := st.settingsMenu.Pick(gtx); closed {
|
||||||
st.settingsMenu = nil
|
st.settingsMenu = nil
|
||||||
switch act {
|
switch act {
|
||||||
case settingsActionSetPassword:
|
case settingsActionSetPassword:
|
||||||
st.setPwDialog = newSetPasswordDialog()
|
st.setPwDialog = newSetPasswordDialog()
|
||||||
|
case settingsActionChangePassword:
|
||||||
|
st.changePwDlg = newChangePasswordDialog()
|
||||||
case settingsActionImportLegacy:
|
case settingsActionImportLegacy:
|
||||||
st.importDialog = newImportLegacyDialog()
|
st.importDialog = newImportLegacyDialog()
|
||||||
case settingsActionPreferences:
|
case settingsActionPreferences:
|
||||||
lang, theme, _, _ := st.store.Preferences()
|
lang, theme, autoLock, _ := st.store.Preferences()
|
||||||
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme))
|
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock)
|
||||||
case settingsActionAbout:
|
case settingsActionAbout:
|
||||||
st.aboutDialog = newAboutDialog()
|
st.aboutDialog = newAboutDialog()
|
||||||
}
|
}
|
||||||
@@ -353,8 +450,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
if st.prefsDialog != nil {
|
if st.prefsDialog != nil {
|
||||||
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
|
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
|
||||||
if !r.cancel {
|
if !r.cancel {
|
||||||
_, _, autoLock, minToTray := st.store.Preferences()
|
_, _, _, minToTray := st.store.Preferences()
|
||||||
st.store.SetPreferences(r.language, string(r.theme), autoLock, minToTray)
|
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, minToTray)
|
||||||
i18n.SetLanguage(r.language)
|
i18n.SetLanguage(r.language)
|
||||||
st.themeMode = r.theme
|
st.themeMode = r.theme
|
||||||
st.theme = nil // force rebuild on next frame
|
st.theme = nil // force rebuild on next frame
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
@@ -9,8 +12,8 @@ import (
|
|||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// preferencesDialog edits the persistent UI prefs: UI language and
|
// preferencesDialog edits the persistent UI prefs: UI language, theme,
|
||||||
// theme. Auto-lock and minimize-to-tray live in their own bucket and
|
// and auto-lock timeout. Minimize-to-tray lives in its own bucket and
|
||||||
// will appear here later.
|
// will appear here later.
|
||||||
type preferencesDialog struct {
|
type preferencesDialog struct {
|
||||||
lang string
|
lang string
|
||||||
@@ -27,6 +30,11 @@ type preferencesDialog struct {
|
|||||||
langZhCNBt widget.Clickable
|
langZhCNBt widget.Clickable
|
||||||
langDeBt 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
|
okBt widget.Clickable
|
||||||
cancelBt widget.Clickable
|
cancelBt widget.Clickable
|
||||||
}
|
}
|
||||||
@@ -34,9 +42,10 @@ type preferencesDialog struct {
|
|||||||
// preferencesResult is what Layout's callback receives. cancel skips
|
// preferencesResult is what Layout's callback receives. cancel skips
|
||||||
// any persistence.
|
// any persistence.
|
||||||
type preferencesResult struct {
|
type preferencesResult struct {
|
||||||
cancel bool
|
cancel bool
|
||||||
language string
|
language string
|
||||||
theme themeMode
|
theme themeMode
|
||||||
|
autoLockMinutes int
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedLanguages is the closed set the prefs dialog exposes. Keep
|
// supportedLanguages is the closed set the prefs dialog exposes. Keep
|
||||||
@@ -50,14 +59,17 @@ var supportedLanguages = []struct {
|
|||||||
{"de", "Deutsch"},
|
{"de", "Deutsch"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPreferencesDialog(currentLang string, currentTheme themeMode) *preferencesDialog {
|
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int) *preferencesDialog {
|
||||||
if currentLang == "" {
|
if currentLang == "" {
|
||||||
currentLang = "en"
|
currentLang = "en"
|
||||||
}
|
}
|
||||||
if currentTheme == "" {
|
if currentTheme == "" {
|
||||||
currentTheme = themeSystem
|
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(
|
func (d *preferencesDialog) Layout(
|
||||||
@@ -69,7 +81,14 @@ func (d *preferencesDialog) Layout(
|
|||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
}
|
}
|
||||||
if d.okBt.Clicked(gtx) {
|
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}
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +146,10 @@ func (d *preferencesDialog) Layout(
|
|||||||
chip(&d.langDeBt, supportedLanguages[2].label, d.lang == "de"),
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type settingsAction int
|
|||||||
const (
|
const (
|
||||||
settingsActionNone settingsAction = iota
|
settingsActionNone settingsAction = iota
|
||||||
settingsActionSetPassword
|
settingsActionSetPassword
|
||||||
|
settingsActionChangePassword
|
||||||
settingsActionImportLegacy
|
settingsActionImportLegacy
|
||||||
settingsActionPreferences
|
settingsActionPreferences
|
||||||
settingsActionAbout
|
settingsActionAbout
|
||||||
@@ -23,11 +24,12 @@ const (
|
|||||||
// settingsMenu is the small popup that opens when the user clicks the
|
// settingsMenu is the small popup that opens when the user clicks the
|
||||||
// gear button in the main window's top bar.
|
// gear button in the main window's top bar.
|
||||||
type settingsMenu struct {
|
type settingsMenu struct {
|
||||||
setPwBtn widget.Clickable
|
setPwBtn widget.Clickable
|
||||||
importBtn widget.Clickable
|
changePwBtn widget.Clickable
|
||||||
prefsBtn widget.Clickable
|
importBtn widget.Clickable
|
||||||
aboutBtn widget.Clickable
|
prefsBtn widget.Clickable
|
||||||
cancelBtn widget.Clickable
|
aboutBtn widget.Clickable
|
||||||
|
cancelBtn widget.Clickable
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSettingsMenu() *settingsMenu { return &settingsMenu{} }
|
func newSettingsMenu() *settingsMenu { return &settingsMenu{} }
|
||||||
@@ -37,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
|
|||||||
switch {
|
switch {
|
||||||
case m.setPwBtn.Clicked(gtx):
|
case m.setPwBtn.Clicked(gtx):
|
||||||
return settingsActionSetPassword, true
|
return settingsActionSetPassword, true
|
||||||
|
case m.changePwBtn.Clicked(gtx):
|
||||||
|
return settingsActionChangePassword, true
|
||||||
case m.importBtn.Clicked(gtx):
|
case m.importBtn.Clicked(gtx):
|
||||||
return settingsActionImportLegacy, true
|
return settingsActionImportLegacy, true
|
||||||
case m.prefsBtn.Clicked(gtx):
|
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(material.H6(th, i18n.T("menu_settings")).Layout),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
row(&m.setPwBtn, i18n.T("menu_set_password")),
|
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.importBtn, i18n.T("menu_import_legacy")),
|
||||||
row(&m.prefsBtn, i18n.T("menu_preferences")),
|
row(&m.prefsBtn, i18n.T("menu_preferences")),
|
||||||
row(&m.aboutBtn, i18n.T("menu_about")),
|
row(&m.aboutBtn, i18n.T("menu_about")),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -141,6 +142,17 @@ func (s *store) SetPassword(pw []byte) {
|
|||||||
s.Push()
|
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.
|
// Encrypted reports whether the store will encrypt the next write.
|
||||||
func (s *store) Encrypted() bool {
|
func (s *store) Encrypted() bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user