From b9d81bba3d969abd211dbe7f3548b29fd5b2ef0b Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 19 Sep 2026 21:44:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E9=AA=8C=E8=AF=81=E8=BE=93=E5=85=A5=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/smail-admin/main.go | 2 +- backend/services/adminreset/password_linux.go | 10 +++++-- .../adminreset/password_unsupported.go | 4 +-- .../services/adminreset/password_windows.go | 28 +++++++++++-------- deploy/systemd/smail-admin.service | 2 +- ...全威胁模型与敏感数据流设计.md | 2 +- ...级管理员安全验证与本机重置.md | 6 ++-- 7 files changed, 32 insertions(+), 22 deletions(-) diff --git a/backend/cmd/smail-admin/main.go b/backend/cmd/smail-admin/main.go index f5206d1..0eeb46c 100644 --- a/backend/cmd/smail-admin/main.go +++ b/backend/cmd/smail-admin/main.go @@ -43,7 +43,7 @@ func run(args []string) int { return 1 } defer conn.Close() - if err := adminreset.SendVerifiedReset(ctx, conn, adminreset.ReadProtectedInput, adminreset.ReadProtectedPassword); err != nil { + if err := adminreset.SendVerifiedReset(ctx, conn, adminreset.ReadVisibleInput, adminreset.ReadProtectedPassword); err != nil { fmt.Fprintln(os.Stderr, "超级管理员重置失败:", safeError(err)) return 1 } diff --git a/backend/services/adminreset/password_linux.go b/backend/services/adminreset/password_linux.go index 3e71715..e7669e8 100644 --- a/backend/services/adminreset/password_linux.go +++ b/backend/services/adminreset/password_linux.go @@ -30,7 +30,7 @@ func ReadProtectedPassword() (string, error) { } func askPassword(prompt string) (string, error) { - value, err := ReadProtectedInput(prompt) + value, err := readSystemdInput(prompt, "no") if err != nil { return "", err } @@ -40,8 +40,12 @@ func askPassword(prompt string) (string, error) { return value, nil } -func ReadProtectedInput(prompt string) (string, error) { - command := exec.Command("systemd-ask-password", "--no-tty", prompt) +func ReadVisibleInput(prompt string) (string, error) { + return readSystemdInput(prompt, "yes") +} + +func readSystemdInput(prompt, echo string) (string, error) { + command := exec.Command("systemd-ask-password", "--no-tty", "--echo="+echo, prompt) command.Stderr = io.Discard output, err := command.Output() if err != nil { diff --git a/backend/services/adminreset/password_unsupported.go b/backend/services/adminreset/password_unsupported.go index 5d86ef4..3a0d033 100644 --- a/backend/services/adminreset/password_unsupported.go +++ b/backend/services/adminreset/password_unsupported.go @@ -2,5 +2,5 @@ package adminreset -func ReadProtectedPassword() (string, error) { return "", ErrUnsupported } -func ReadProtectedInput(string) (string, error) { return "", ErrUnsupported } +func ReadProtectedPassword() (string, error) { return "", ErrUnsupported } +func ReadVisibleInput(string) (string, error) { return "", ErrUnsupported } diff --git a/backend/services/adminreset/password_windows.go b/backend/services/adminreset/password_windows.go index 9b07eb1..dac8ce9 100644 --- a/backend/services/adminreset/password_windows.go +++ b/backend/services/adminreset/password_windows.go @@ -3,9 +3,11 @@ package adminreset import ( + "bufio" "errors" "fmt" "os" + "strings" "git.pcux.cn/xuans/Smail/utils/password" @@ -35,9 +37,18 @@ func ReadProtectedPassword() (string, error) { } func readWindowsPassword(prompt string) (string, error) { - value, err := ReadProtectedInput(prompt) + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.New("受保护的密码输入需要交互式控制台") + } + fmt.Fprint(os.Stderr, prompt+": ") + valueBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) if err != nil { - return "", err + return "", errors.New("受保护的密码输入失败") + } + value := string(valueBytes) + for index := range valueBytes { + valueBytes[index] = 0 } if password.ValidateUserPassword(value) != nil { return "", errors.New("密码长度必须为 8 到 12 个字符") @@ -45,19 +56,14 @@ func readWindowsPassword(prompt string) (string, error) { return value, nil } -func ReadProtectedInput(prompt string) (string, error) { +func ReadVisibleInput(prompt string) (string, error) { if !term.IsTerminal(int(os.Stdin.Fd())) { return "", errors.New("安全验证需要交互式控制台") } fmt.Fprint(os.Stderr, prompt+": ") - value, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprintln(os.Stderr) + value, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { - return "", errors.New("受保护的密码输入失败") + return "", errors.New("读取安全验证输入失败") } - normalized := string(value) - for i := range value { - value[i] = 0 - } - return normalized, nil + return strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r"), nil } diff --git a/deploy/systemd/smail-admin.service b/deploy/systemd/smail-admin.service index e25fc87..897838b 100644 --- a/deploy/systemd/smail-admin.service +++ b/deploy/systemd/smail-admin.service @@ -18,7 +18,7 @@ PrivateNetwork=true PrivateDevices=true ProtectHome=true ProtectSystem=strict -# 密码代理只在本机临时目录收集无回显输入,不允许写入业务配置或数据库。 +# 密码代理在本机临时目录收集交互输入;验证内容可见,新密码不回显,且均不写入业务配置或数据库。 ReadWritePaths=/run/systemd/ask-password ProtectKernelTunables=true ProtectKernelModules=true diff --git a/docs/安全威胁模型与敏感数据流设计.md b/docs/安全威胁模型与敏感数据流设计.md index 0a5038c..9f3b112 100644 --- a/docs/安全威胁模型与敏感数据流设计.md +++ b/docs/安全威胁模型与敏感数据流设计.md @@ -106,7 +106,7 @@ flowchart LR ### 4.1 部署端超级管理员重置 -1. 部署人员在受控主机上按正式部署命令启动 `smail-admin`,安全凭据与新密码从无回显交互读取,不通过命令行参数、环境变量、服务名或普通文件传递。 +1. 部署人员在受控主机上按正式部署命令启动 `smail-admin`,验证方式和安全凭据使用可见交互输入,新密码使用无回显输入;这些内容均不通过命令行参数、环境变量、服务名或普通文件传递。 2. `smail-admin` 连接固定本机 IPC 地址:Linux 为 Unix Domain Socket,Windows 为 Named Pipe。 3. 主服务先校验对端身份,再签发一次性 nonce 和可用安全方式;客户端依次选择方式、验证邮箱验证码或 TOTP 或安全码,最后才提交新密码。选择邮箱前不发送验证码。 4. 主服务校验协议版本、单帧大小、字段集合、密码长度、nonce 一致性和 nonce 未过期/未消费。 diff --git a/docs/超级管理员安全验证与本机重置.md b/docs/超级管理员安全验证与本机重置.md index c577b41..bc7f099 100644 --- a/docs/超级管理员安全验证与本机重置.md +++ b/docs/超级管理员安全验证与本机重置.md @@ -20,10 +20,10 @@ systemctl start smail-admin ``` -1. 输入可用验证方式的序号。仅显示已配置且有效的方式。 +1. 输入可用验证方式的序号。仅显示已配置且有效的方式,输入的序号会正常显示。 2. 选择邮箱后才发送验证码;选择动态码或安全码不发邮件。 -3. 输入邮箱验证码、手机验证器动态码或一个安全码。 -4. 验证通过后,输入两次新密码。 +3. 输入邮箱验证码、手机验证器动态码或一个安全码,验证内容会正常显示,便于核对。 +4. 验证通过后,输入两次新密码;只有密码输入不显示。 5. 事务成功后旧会话失效,旧安全码和协议凭据撤销。已绑定邮箱和 TOTP 保留。 未绑定邮箱时可以使用 TOTP 或安全码。若唯一方式是安全码,重置后重新登录会要求再次配置安全方式。新安全码只展示一次,请妥善保存。