From 6b75706b8ed22dc0b574802bc9d6a94eb2b6d3d5 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 25 Aug 2026 22:30:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=AA=E6=8E=88=E6=9D=83=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=8F=A3=E8=A1=A5=E5=93=91=20bcrypt=20=E6=AF=94=E8=BE=83?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E6=88=B7=E4=B8=8D=E5=AD=98=E5=9C=A8=E4=B8=8E?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E9=94=99=E8=AF=AF=E8=80=97=E6=97=B6=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=EF=BC=9B=E7=A6=81=E7=94=A8=E8=B4=A6=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=BF=94=E5=9B=9E=E4=B8=8D=E5=90=8C=E6=96=87=E6=A1=88?= =?UTF-8?q?=EF=BC=8C=E5=A0=B5=E4=BD=8F=E7=94=A8=E6=88=B7=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E3=80=82metric=20=E6=8C=81=E5=B9=B3=208=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126} --- .auto/log.jsonl | 1 + internal/apps/user/routers.go | 7 ++++--- pkg/util/password.go | 30 +++++++++++++++++++++++++++++- pkg/util/password_test.go | 23 +++++++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 pkg/util/password_test.go diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 51f0e8c5..7e89d61d 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -30,3 +30,4 @@ {"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}} {"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}} {"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}} +{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}} diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 3f602553..8ffc598a 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -15,7 +15,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/listener" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared" + pkgu "github.com/Rain-kl/Wavelet/pkg/util" "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-contrib/sessions" @@ -96,7 +96,7 @@ func setLoginSession(ctx context.Context, c *gin.Context, user *model.User) erro // @Produce json // @Param request body user.loginRequest true "登录请求参数" // @Success 200 {object} response.Any{data=oauth.BasicUserInfo} "登录成功,返回用户信息" -// @Failure 400 {object} response.Any "用户名或密码错误、帐号已禁用等" +// @Failure 400 {object} response.Any "用户名或密码错误" // @Failure 500 {object} response.Any "服务内部错误" // @Router /api/v1/user/login [post] func Login(c *gin.Context) { @@ -118,13 +118,14 @@ func Login(c *gin.Context) { user, err := getUserByUsernameOrEmail(ctx, req.Username) if err != nil { + pkgu.DummyCheckPassword(req.Password) logger.WarnF(ctx, "[LoginAudit] failed login attempt (username not found) for input: %s, IP: %s", req.Username, c.ClientIP()) response.AbortBadRequest(c, errUsernameOrPasswordWrong) return } if !user.IsActive { logger.WarnF(ctx, "[LoginAudit] banned user login attempt for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP()) - response.AbortBadRequest(c, shared.BannedAccount) + response.AbortBadRequest(c, errUsernameOrPasswordWrong) return } diff --git a/pkg/util/password.go b/pkg/util/password.go index 1e0d4ec1..84060e37 100644 --- a/pkg/util/password.go +++ b/pkg/util/password.go @@ -3,7 +3,11 @@ package util -import "golang.org/x/crypto/bcrypt" +import ( + "sync" + + "golang.org/x/crypto/bcrypt" +) // HashPassword 使用 bcrypt 对密码进行哈希处理 func HashPassword(password string) (string, error) { @@ -14,7 +18,31 @@ func HashPassword(password string) (string, error) { return string(hash), nil } +var dummyPasswordHashOnce sync.Once +var dummyPasswordHash string + +func dummyHash() string { + dummyPasswordHashOnce.Do(func() { + hash, err := bcrypt.GenerateFromPassword([]byte("x"), bcrypt.DefaultCost) + if err != nil { + return + } + dummyPasswordHash = string(hash) + }) + return dummyPasswordHash +} + // CheckPasswordHash 比较 bcrypt 哈希值与明文密码是否匹配 func CheckPasswordHash(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } + +// DummyCheckPassword runs a bcrypt compare against a dummy hash so missing-user +// login failures take a similar amount of time as a real password miss. +func DummyCheckPassword(password string) { + hash := dummyHash() + if hash == "" { + return + } + _ = CheckPasswordHash(hash, password) +} diff --git a/pkg/util/password_test.go b/pkg/util/password_test.go new file mode 100644 index 00000000..c5ec5a4f --- /dev/null +++ b/pkg/util/password_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "testing" + +func TestDummyCheckPasswordDoesNotPanic(t *testing.T) { + DummyCheckPassword("any-password") +} + +func TestCheckPasswordHashRoundTrip(t *testing.T) { + hash, err := HashPassword("secret-pass") + if err != nil { + t.Fatal(err) + } + if !CheckPasswordHash(hash, "secret-pass") { + t.Fatal("expected matching password to succeed") + } + if CheckPasswordHash(hash, "other-pass") { + t.Fatal("expected mismatched password to fail") + } +}