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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user