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"
|
||||
"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) {
|
||||
|
||||
@@ -419,3 +419,32 @@ other = "MIT"
|
||||
|
||||
[about_credits]
|
||||
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]
|
||||
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]
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
@@ -9,8 +12,8 @@ import (
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||
)
|
||||
|
||||
// preferencesDialog edits the persistent UI prefs: UI language and
|
||||
// theme. Auto-lock and minimize-to-tray live in their own bucket and
|
||||
// preferencesDialog edits the persistent UI prefs: UI language, theme,
|
||||
// and auto-lock timeout. Minimize-to-tray lives in its own bucket and
|
||||
// will appear here later.
|
||||
type preferencesDialog struct {
|
||||
lang string
|
||||
@@ -27,6 +30,11 @@ type preferencesDialog struct {
|
||||
langZhCNBt 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
|
||||
cancelBt widget.Clickable
|
||||
}
|
||||
@@ -34,9 +42,10 @@ type preferencesDialog struct {
|
||||
// preferencesResult is what Layout's callback receives. cancel skips
|
||||
// any persistence.
|
||||
type preferencesResult struct {
|
||||
cancel bool
|
||||
language string
|
||||
theme themeMode
|
||||
cancel bool
|
||||
language string
|
||||
theme themeMode
|
||||
autoLockMinutes int
|
||||
}
|
||||
|
||||
// supportedLanguages is the closed set the prefs dialog exposes. Keep
|
||||
@@ -50,14 +59,17 @@ var supportedLanguages = []struct {
|
||||
{"de", "Deutsch"},
|
||||
}
|
||||
|
||||
func newPreferencesDialog(currentLang string, currentTheme themeMode) *preferencesDialog {
|
||||
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int) *preferencesDialog {
|
||||
if currentLang == "" {
|
||||
currentLang = "en"
|
||||
}
|
||||
if currentTheme == "" {
|
||||
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(
|
||||
@@ -69,7 +81,14 @@ func (d *preferencesDialog) Layout(
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -127,6 +146,10 @@ func (d *preferencesDialog) Layout(
|
||||
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 (
|
||||
settingsActionNone settingsAction = iota
|
||||
settingsActionSetPassword
|
||||
settingsActionChangePassword
|
||||
settingsActionImportLegacy
|
||||
settingsActionPreferences
|
||||
settingsActionAbout
|
||||
@@ -23,11 +24,12 @@ const (
|
||||
// settingsMenu is the small popup that opens when the user clicks the
|
||||
// gear button in the main window's top bar.
|
||||
type settingsMenu struct {
|
||||
setPwBtn widget.Clickable
|
||||
importBtn widget.Clickable
|
||||
prefsBtn widget.Clickable
|
||||
aboutBtn widget.Clickable
|
||||
cancelBtn widget.Clickable
|
||||
setPwBtn widget.Clickable
|
||||
changePwBtn widget.Clickable
|
||||
importBtn widget.Clickable
|
||||
prefsBtn widget.Clickable
|
||||
aboutBtn widget.Clickable
|
||||
cancelBtn widget.Clickable
|
||||
}
|
||||
|
||||
func newSettingsMenu() *settingsMenu { return &settingsMenu{} }
|
||||
@@ -37,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
|
||||
switch {
|
||||
case m.setPwBtn.Clicked(gtx):
|
||||
return settingsActionSetPassword, true
|
||||
case m.changePwBtn.Clicked(gtx):
|
||||
return settingsActionChangePassword, true
|
||||
case m.importBtn.Clicked(gtx):
|
||||
return settingsActionImportLegacy, true
|
||||
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(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
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.prefsBtn, i18n.T("menu_preferences")),
|
||||
row(&m.aboutBtn, i18n.T("menu_about")),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -141,6 +142,17 @@ func (s *store) SetPassword(pw []byte) {
|
||||
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.
|
||||
func (s *store) Encrypted() bool {
|
||||
s.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user