diff --git a/backend/controllers/login.go b/backend/controllers/login.go index 740dec0..2114bcb 100644 --- a/backend/controllers/login.go +++ b/backend/controllers/login.go @@ -202,6 +202,9 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) { user, err := auth.AuthenticateIdentifier(identifier, reqData.Password) if err != nil { + if writePasswordBusy(w, err) { + return + } loginPairAttempts.recordFailure(pairKey) loginAccountAttempts.recordFailure(accountKey) loginIPAttempts.recordFailure(ipKey) diff --git a/backend/controllers/password_capacity.go b/backend/controllers/password_capacity.go new file mode 100644 index 0000000..65473fa --- /dev/null +++ b/backend/controllers/password_capacity.go @@ -0,0 +1,22 @@ +package controllers + +import ( + "errors" + "net/http" + + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/utils/password" +) + +// 计算繁忙不是凭据错误,不计入失败次数,也不触发会话失效。 +func writePasswordBusy(w http.ResponseWriter, err error) bool { + if !errors.Is(err, password.ErrBusy) { + return false + } + w.Header().Set("Retry-After", "1") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + response.NewErrorResponse(http.StatusServiceUnavailable, "认证服务繁忙,请稍后重试", response.ConfigErrorData{Code: "AUTH_BUSY"}).FPrint(w) + return true +} diff --git a/backend/controllers/password_capacity_test.go b/backend/controllers/password_capacity_test.go new file mode 100644 index 0000000..e7b7354 --- /dev/null +++ b/backend/controllers/password_capacity_test.go @@ -0,0 +1,86 @@ +package controllers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/session" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/password" + "github.com/alexedwards/scs/v2" +) + +func TestPasswordBusyHTTPDoesNotLockAccounts(t *testing.T) { + oldConfig, oldDB, oldSession := config.Instance, db.Instance, session.Instance + oldPair, oldAccount, oldIP := loginPairAttempts, loginAccountAttempts, loginIPAttempts + oldReauthUser, oldReauthIP := reauthUserAttempts, reauthIPAttempts + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "capacity.db")} + if err := db.Init(""); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = db.Instance.Close() + config.Instance, db.Instance, session.Instance = oldConfig, oldDB, oldSession + loginPairAttempts, loginAccountAttempts, loginIPAttempts = oldPair, oldAccount, oldIP + reauthUserAttempts, reauthIPAttempts = oldReauthUser, oldReauthIP + }) + loginPairAttempts, loginAccountAttempts, loginIPAttempts = newLoginLimiter(1), newLoginLimiter(1), newLoginLimiter(1) + reauthUserAttempts, reauthIPAttempts = newLoginLimiter(1), newLoginLimiter(1) + session.Instance = scs.New() + user := models.User{Account: "capacity-user", SessionVersion: 1} + if err := auth.SetPassword(&user, "CorrectP12"); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + release := passwordtest.Hold(t) + cases := []struct { + handler func(*appcontext.Context, http.ResponseWriter, *http.Request) + body string + }{ + {Login, `{"account":"capacity-user","password":"CorrectP12"}`}, + {Login, `{"account":"missing-user","password":"CorrectP12"}`}, + {ReauthenticateCurrentUser, `{"password":"CorrectP12"}`}, + {ModifyPassword, `{"current_password":"CorrectP12","password":"NewPass123"}`}, + } + for index := 0; index < 12; index++ { + for _, item := range cases { + request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/api/test", strings.NewReader(item.body)) + request.RemoteAddr = "192.0.2.71:1234" + recorder := httptest.NewRecorder() + ctx := &appcontext.Context{Context: request.Context(), UserID: user.ID, UserAccount: user.Account} + item.handler(ctx, recorder, request) + if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" || !strings.Contains(recorder.Body.String(), "AUTH_BUSY") { + t.Fatalf("认证繁忙响应不正确:%d %s", recorder.Code, recorder.Body.String()) + } + } + } + for _, limiter := range []*loginLimiter{loginPairAttempts, loginAccountAttempts, loginIPAttempts, reauthUserAttempts, reauthIPAttempts} { + if len(limiter.attempts) != 0 { + t.Fatal("认证繁忙增加了登录失败计数") + } + } + release() + if _, err := auth.Authenticate(user.Account, "CorrectP12"); err != nil { + t.Fatal(err) + } +} + +func TestPasswordBusyResponsePreservesOtherErrors(t *testing.T) { + if writePasswordBusy(httptest.NewRecorder(), fmt.Errorf("wrapped: %w", password.ErrInvalidLength)) { + t.Fatal("普通错误被错误标记为资源繁忙") + } + if !writePasswordBusy(httptest.NewRecorder(), fmt.Errorf("wrapped: %w", password.ErrBusy)) { + t.Fatal("包装后的繁忙错误未被识别") + } +} diff --git a/backend/controllers/reauth.go b/backend/controllers/reauth.go index 04f35d6..8e2c269 100644 --- a/backend/controllers/reauth.go +++ b/backend/controllers/reauth.go @@ -49,6 +49,9 @@ func ReauthenticateCurrentUser(ctx *appcontext.Context, w http.ResponseWriter, r user, err := auth.AuthenticateUserByID(ctx.UserID, input.Password) if err != nil { + if writePasswordBusy(w, err) { + return + } reauthUserAttempts.recordFailure(userKey) reauthIPAttempts.recordFailure(ipKey) log.WithContext(ctx).Info("reauthentication failed") diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 73718c7..e859b12 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -24,6 +24,7 @@ import ( "git.pcux.cn/xuans/Smail/services/verification" webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn" "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/password" log "github.com/sirupsen/logrus" ) @@ -279,6 +280,9 @@ func SetSecurityQuestion(ctx *context.Context, w http.ResponseWriter, req *http. return } if err := service.SetSecurityQuestion(ctx.UserID, input.Question, input.Answer); err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, "密保问题设置失败") return } @@ -308,6 +312,9 @@ func GenerateRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *htt } codes, err := service.GenerateRecoveryCodes(ctx.UserID, input.Count) if err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, "离线恢复码生成失败") return } @@ -424,6 +431,9 @@ func GenerateProtocolCredential(ctx *context.Context, w http.ResponseWriter, req } code, err := service.GenerateProtocolCode(ctx.UserID, input.Protocol) if err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, "请先开启协议后再生成授权码") return } @@ -464,6 +474,9 @@ func RevealProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req return } if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, "密码验证失败") return } @@ -617,6 +630,9 @@ func VerifyPasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *ht } } if err != nil { + if writePasswordBusy(w, err) { + return + } emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "验证失败") return @@ -647,6 +663,9 @@ func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req * return } if err := runtime.recovery.CompleteWithContext(input.Ticket, input.NewPassword, browserContext); err != nil { + if writePasswordBusy(w, err) { + return + } emailRecoveryFailureIPAttempts.recordFailure(failureKey) writeSecurityError(w, response.ParamsError, "重置失败") return @@ -696,7 +715,16 @@ func CompletePublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request return } runtime := currentSecurityRuntime() - if runtime.recovery == nil || runtime.recovery.CompletePublicLink(input.Token, input.NewPassword, input.RecoveryCode) != nil { + if runtime.recovery == nil { + writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false}) + return + } + if err := runtime.recovery.CompletePublicLink(input.Token, input.NewPassword, input.RecoveryCode); err != nil { + if errors.Is(err, password.ErrBusy) { + w.Header().Set("Retry-After", "1") + writePublicRecoveryJSON(w, http.StatusServiceUnavailable, map[string]bool{"reset": false}) + return + } writePublicRecoveryJSON(w, http.StatusBadRequest, map[string]bool{"reset": false}) return } diff --git a/backend/controllers/security_client_credentials.go b/backend/controllers/security_client_credentials.go index 814205f..a9f5513 100644 --- a/backend/controllers/security_client_credentials.go +++ b/backend/controllers/security_client_credentials.go @@ -1,4 +1,4 @@ -package controllers +package controllers import ( "net/http" @@ -38,6 +38,9 @@ func CreateClientCredential(ctx *context.Context, w http.ResponseWriter, req *ht } code, item, err := service.CreateClientCredential(ctx.UserID, input.ClientName) if err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, err.Error()) return } @@ -59,6 +62,9 @@ func RegenerateClientCredential(ctx *context.Context, w http.ResponseWriter, req } code, err := service.RegenerateClientCredential(ctx.UserID, input.ClientID) if err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, err.Error()) return } @@ -99,6 +105,9 @@ func RevealClientCredentials(ctx *context.Context, w http.ResponseWriter, req *h return } if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil { + if writePasswordBusy(w, err) { + return + } writeSecurityError(w, response.ParamsError, "密码验证失败") return } diff --git a/backend/controllers/settings.go b/backend/controllers/settings.go index c2f915c..77f7f9d 100644 --- a/backend/controllers/settings.go +++ b/backend/controllers/settings.go @@ -62,6 +62,9 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque currentUser, err = auth.Authenticate(ctx.UserAccount, retData.CurrentPassword) } if err != nil || currentUser == nil || currentUser.ID != ctx.UserID { + if writePasswordBusy(w, err) { + return + } loginPairAttempts.recordFailure(pairKey) loginAccountAttempts.recordFailure(accountKey) loginIPAttempts.recordFailure(ipKey) @@ -89,6 +92,9 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque PasswordChangedAt: &now, } if err := auth.SetPassword(&user, retData.Password); err != nil { + if writePasswordBusy(w, err) { + return + } if errors.Is(err, password.ErrInvalidLength) { response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w) return diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 1068524..4b9a6e4 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -86,6 +86,9 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) user := models.User{Name: username, Account: account, Disabled: reqData.Disabled, SessionVersion: 1, SecurityVersion: 1} user.ApplyRole(targetRole) if err := auth.SetPassword(&user, reqData.Password); err != nil { + if writePasswordBusy(w, err) { + return + } if errors.Is(err, password.ErrInvalidLength) { response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w) return @@ -337,6 +340,9 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { user.Disabled = reqData.Disabled if reqData.Password != "" { if err := auth.SetPassword(&user, reqData.Password); err != nil { + if writePasswordBusy(w, err) { + return + } if errors.Is(err, password.ErrInvalidLength) { response.NewErrorResponse(response.ParamsError, "密码长度必须为 8-12 字节", "").FPrint(w) return diff --git a/backend/internal/passwordtest/hold.go b/backend/internal/passwordtest/hold.go new file mode 100644 index 0000000..343485f --- /dev/null +++ b/backend/internal/passwordtest/hold.go @@ -0,0 +1,63 @@ +// Package passwordtest 为串行安全测试占用真实密码计算名额,不执行额外的昂贵计算。 +package passwordtest + +import ( + "crypto/rand" + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/utils/password" +) + +type heldReader struct { + original io.Reader + entered chan struct{} + resume chan struct{} + enteredOnce atomic.Bool +} + +func (r *heldReader) Read(p []byte) (int, error) { + if r.enteredOnce.CompareAndSwap(false, true) { + close(r.entered) + <-r.resume + panic(r) + } + return r.original.Read(p) +} + +// Hold 仅供非并行测试使用,释放时同时验证异常路径没有泄漏计算名额。 +func Hold(t *testing.T) func() { + t.Helper() + r := &heldReader{original: rand.Reader, entered: make(chan struct{}), resume: make(chan struct{})} + rand.Reader = r + done := make(chan any, 1) + go func() { + defer func() { done <- recover() }() + _, _ = password.Hash("HeldPass123") + }() + var once sync.Once + release := func() { + once.Do(func() { + close(r.resume) + defer func() { rand.Reader = r.original }() + select { + case result := <-done: + if result != r { + t.Errorf("密码测试占位未经过预期的异常释放路径") + } + case <-time.After(5 * time.Second): + t.Error("密码测试占位释放超时") + } + }) + } + t.Cleanup(release) + select { + case <-r.entered: + case <-time.After(5 * time.Second): + t.Fatal("密码计算名额未被测试请求占用") + } + return release +} diff --git a/backend/listen/imap_server/session_login.go b/backend/listen/imap_server/session_login.go index 149c672..c65f5c0 100644 --- a/backend/listen/imap_server/session_login.go +++ b/backend/listen/imap_server/session_login.go @@ -1,8 +1,11 @@ package imap_server import ( + "errors" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/utils/password" "github.com/emersion/go-imap/v2" log "github.com/sirupsen/logrus" ) @@ -10,6 +13,9 @@ import ( func (s *serverSession) Login(username, pwd string) error { user, err := auth.AuthenticateProtocol("imap", s.remoteAddress, username, pwd) if err != nil { + if errors.Is(err, password.ErrBusy) { + return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeUnavailable, Text: "Authentication temporarily unavailable"} + } log.WithContext(s.ctx).WithFields(log.Fields{ "action": "IMAP 客户端登录", "result": "failure", @@ -34,4 +40,4 @@ func (s *serverSession) Login(username, pwd string) error { "object": s.remoteAddress, }).Debug("IMAP 客户端认证成功") return nil -} \ No newline at end of file +} diff --git a/backend/listen/pop3_server/action.go b/backend/listen/pop3_server/action.go index 51cff51..a73b010 100644 --- a/backend/listen/pop3_server/action.go +++ b/backend/listen/pop3_server/action.go @@ -16,6 +16,7 @@ import ( "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/errors" "git.pcux.cn/xuans/Smail/utils/id" + "git.pcux.cn/xuans/Smail/utils/password" log "github.com/sirupsen/logrus" "github.com/spf13/cast" "strconv" @@ -105,6 +106,9 @@ func (a action) Pass(session *gopop.Session, pwd string) error { } user, err := auth.AuthenticateProtocol("pop3", remoteAddress, session.User, pwd) if err != nil { + if errors2.Is(err, password.ErrBusy) { + return errors2.New("[SYS/TEMP] Authentication temporarily unavailable") + } log.WithContext(session.Ctx).WithFields(log.Fields{ "action": "POP3 客户端登录", "result": "failure", diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go index cc07e50..e434314 100644 --- a/backend/listen/smtp_server/action.go +++ b/backend/listen/smtp_server/action.go @@ -6,6 +6,7 @@ import ( "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/id" + "git.pcux.cn/xuans/Smail/utils/password" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" log "github.com/sirupsen/logrus" @@ -64,6 +65,9 @@ func (s *Session) Auth(mech string) (sasl.Server, error) { func (s *Session) AuthPlain(username, pwd string) error { user, err := auth.AuthenticateProtocol("smtp", s.RemoteAddress.String(), username, pwd) if err != nil { + if errors.Is(err, password.ErrBusy) { + return &smtp.SMTPError{Code: 454, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: "Temporary authentication failure"} + } log.WithContext(s.Ctx).WithFields(log.Fields{ "action": "SMTP 客户端认证", "result": "failure", diff --git a/backend/services/auth/auth.go b/backend/services/auth/auth.go index 923c14e..e2f384e 100644 --- a/backend/services/auth/auth.go +++ b/backend/services/auth/auth.go @@ -28,7 +28,9 @@ func AuthenticateIdentifier(identifier, plain string) (*models.User, error) { identity, err := ResolveIdentifier(identifier) if err != nil { if plain != "" { - password.VerifyDummy(plain) + if err := password.CheckDummy(plain); err != nil { + return nil, err + } } return nil, errors.New("invalid credentials") } @@ -57,7 +59,9 @@ func AuthenticateUserByID(userID int, plain string) (*models.User, error) { func authenticateUserByID(userID int, plain string) (*models.User, error) { if userID <= 0 { if plain != "" { - password.VerifyDummy(plain) + if err := password.CheckDummy(plain); err != nil { + return nil, err + } } return nil, errors.New("invalid credentials") } @@ -72,16 +76,24 @@ func authenticateUserByID(userID int, plain string) (*models.User, error) { func authenticateLoadedUser(user *models.User, has bool, err error, plain string) (*models.User, error) { if err != nil { if errors.Is(err, sql.ErrNoRows) { - password.VerifyDummy(plain) + if err := password.CheckDummy(plain); err != nil { + return nil, err + } return nil, errors.New("invalid credentials") } return nil, fmt.Errorf("load user: %w", err) } if !has || user.ID == 0 { - password.VerifyDummy(plain) + if err := password.CheckDummy(plain); err != nil { + return nil, err + } return nil, errors.New("invalid credentials") } - if !password.Verify(user.Password, plain) { + matched, err := password.Check(user.Password, plain) + if err != nil { + return nil, err + } + if !matched { return nil, errors.New("invalid credentials") } return user, nil diff --git a/backend/services/auth/capacity_test.go b/backend/services/auth/capacity_test.go new file mode 100644 index 0000000..dd29a8f --- /dev/null +++ b/backend/services/auth/capacity_test.go @@ -0,0 +1,73 @@ +package auth + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/utils/password" +) + +func TestProtocolBusyDoesNotCountAsCredentialFailure(t *testing.T) { + oldConfig, oldDB := config.Instance, db.Instance + oldPair, oldAccount, oldRemote := protocolPairAttempts, protocolAccountAttempts, protocolRemoteAttempts + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "capacity.db"), Domain: "example.com"} + if err := db.Init(""); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = db.Instance.Close() + config.Instance, db.Instance = oldConfig, oldDB + protocolPairAttempts, protocolAccountAttempts, protocolRemoteAttempts = oldPair, oldAccount, oldRemote + }) + protocolPairAttempts = newProtocolLoginLimiter(1) + protocolAccountAttempts = newProtocolLoginLimiter(1) + protocolRemoteAttempts = newProtocolLoginLimiter(1) + user := models.User{Account: "capacity-user"} + if err := SetPassword(&user, "CorrectP12"); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + address, now := "capacity-user@example.com", time.Now().UTC() + if _, err := db.Instance.Insert(&models.UserEmail{UserID: user.ID, Address: address, AddressCanonical: &address, VerifiedAt: &now}); err != nil { + t.Fatal(err) + } + service, err := credentials.NewService(db.Instance) + if err != nil { + t.Fatal(err) + } + code, _, err := service.CreateClientCredential(user.ID, "容量验收") + if err != nil { + t.Fatal(err) + } + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + for _, protocol := range []string{"smtp", "imap", "pop3"} { + if got, err := AuthenticateProtocol(protocol, "192.0.2.70:1234", address, code); got != nil || !errors.Is(err, password.ErrBusy) { + t.Fatalf("%s 繁忙被当成凭据失败:%v", protocol, err) + } + } + } + for _, account := range []string{user.Account, "missing-user"} { + if _, err := Authenticate(account, "CorrectP12"); !errors.Is(err, password.ErrBusy) { + t.Fatalf("真实或模拟密码校验未传递繁忙:%v", err) + } + } + if len(protocolPairAttempts.attempts)+len(protocolAccountAttempts.attempts)+len(protocolRemoteAttempts.attempts) != 0 { + t.Fatal("资源繁忙增加了协议失败计数") + } + release() + for _, protocol := range []string{"smtp", "imap", "pop3"} { + if got, err := AuthenticateProtocol(protocol, "192.0.2.70:1234", address, code); err != nil || got.ID != user.ID { + t.Fatalf("%s 繁忙结束后无法正常登录:%v", protocol, err) + } + } +} diff --git a/backend/services/auth/protocol_limiter.go b/backend/services/auth/protocol_limiter.go index 24f9f9c..220f163 100644 --- a/backend/services/auth/protocol_limiter.go +++ b/backend/services/auth/protocol_limiter.go @@ -12,6 +12,7 @@ import ( "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/utils/password" ) const ( @@ -81,7 +82,14 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string, } service, err := credentials.NewService(db.Instance) - if err != nil || !service.VerifyClientOrProtocolCode(userID, normalizedProtocol, plain) { + if err != nil { + return fail() + } + matched, err := service.CheckClientOrProtocolCode(userID, normalizedProtocol, plain) + if errors.Is(err, password.ErrBusy) { + return nil, err + } + if err != nil || !matched { return fail() } var user models.User diff --git a/backend/services/credentials/capacity_test.go b/backend/services/credentials/capacity_test.go new file mode 100644 index 0000000..d014c3c --- /dev/null +++ b/backend/services/credentials/capacity_test.go @@ -0,0 +1,54 @@ +package credentials + +import ( + "errors" + "testing" + + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/password" +) + +func TestCredentialBusyDoesNotConsumeOrReplaceCodes(t *testing.T) { + service := newFixture(t) + if err := service.engine.Sync2(&models.ClientCredential{}); err != nil { + t.Fatal(err) + } + if err := service.SetSecurityQuestion(1, "问题", "answer"); err != nil { + t.Fatal(err) + } + if err := service.SetProtocolEnabled(1, ProtocolIMAP, true); err != nil { + t.Fatal(err) + } + protocolCode, err := service.GenerateProtocolCode(1, ProtocolIMAP) + if err != nil { + t.Fatal(err) + } + codes, err := service.GenerateRecoveryCodes(1, 5) + if err != nil { + t.Fatal(err) + } + release := passwordtest.Hold(t) + if _, err := service.VerifySecurityQuestion(1, "answer"); !errors.Is(err, password.ErrBusy) { + t.Fatal(err) + } + if _, err := service.CheckProtocolCode(1, ProtocolIMAP, protocolCode); !errors.Is(err, password.ErrBusy) { + t.Fatal(err) + } + if _, err := service.CheckClientOrProtocolCode(1, ProtocolIMAP, protocolCode); !errors.Is(err, password.ErrBusy) { + t.Fatal(err) + } + if err := service.ConsumeRecoveryCode(1, codes[0]); !errors.Is(err, password.ErrBusy) { + t.Fatal(err) + } + if _, err := service.GenerateRecoveryCodes(1, 5); !errors.Is(err, password.ErrBusy) { + t.Fatal(err) + } + release() + if err := service.ConsumeRecoveryCode(1, codes[0]); err != nil { + t.Fatalf("繁忙后恢复码已被错误消费或撤销:%v", err) + } + if _, err := service.CheckProtocolCode(1, ProtocolIMAP, protocolCode); err != nil { + t.Fatal(err) + } +} diff --git a/backend/services/credentials/client.go b/backend/services/credentials/client.go index c0f54ff..609291a 100644 --- a/backend/services/credentials/client.go +++ b/backend/services/credentials/client.go @@ -1,4 +1,4 @@ -package credentials +package credentials import ( "errors" @@ -286,14 +286,18 @@ func (s *Service) RevealClientCredentials(userID int) (map[int]string, error) { // VerifyClientOrProtocolCode 同时支持客户端专属授权码以及遗留的协议授权码 func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPlain string) bool { + matched, _ := s.CheckClientOrProtocolCode(userID, protocol, rawPlain) + return matched +} + +// CheckClientOrProtocolCode 将计算过载向上返回,避免被当成密码错误累计封禁。 +func (s *Service) CheckClientOrProtocolCode(userID int, protocol string, rawPlain string) (bool, error) { if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 { - password.VerifyDummy(rawPlain) - return false + return false, password.CheckDummy(rawPlain) } cleanedPlain := CleanAuthCode(rawPlain) if cleanedPlain == "" { - password.VerifyDummy(rawPlain) - return false + return false, password.CheckDummy(rawPlain) } // 1. 优先校验客户端专属授权码 @@ -302,12 +306,22 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla if err == nil && len(clients) > 0 { for _, client := range clients { // 比对清洗后的统一大写无横线授权码 - if password.Verify(client.CodeHash, cleanedPlain) { + matched, err := password.Check(client.CodeHash, cleanedPlain) + if err != nil { + return false, err + } + if matched { now := s.now() - _, _ = s.engine.ID(client.ID). + affected, err := s.engine.ID(client.ID). Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash). Cols("last_used_at", "updated_at").Update(&models.ClientCredential{LastUsedAt: &now}) - return true + if err != nil || affected == 1 { + return err == nil, err + } + valid, err := s.engine.ID(client.ID). + Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash). + Exist(&models.ClientCredential{}) + return valid, err } } } @@ -315,20 +329,25 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla // 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain) protocol, err = NormalizeProtocol(protocol) if err != nil { - password.VerifyDummy(rawPlain) - return false + return false, password.CheckDummy(rawPlain) } var item models.ProtocolCredential has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item) if err != nil || !has || item.CodeHash == "" { - password.VerifyDummy(rawPlain) - return false + return false, password.CheckDummy(rawPlain) } // 兼容旧哈希直接比对原始输入,或者比对清洗后的输入 - matched := password.Verify(item.CodeHash, strings.TrimSpace(rawPlain)) || password.Verify(item.CodeHash, cleanedPlain) - if !matched { - return false + trimmedPlain := strings.TrimSpace(rawPlain) + matched, err := password.Check(item.CodeHash, trimmedPlain) + if err != nil { + return false, err + } + if !matched && trimmedPlain != cleanedPlain { + matched, err = password.Check(item.CodeHash, cleanedPlain) + } + if err != nil || !matched { + return false, err } now := s.now() @@ -336,11 +355,14 @@ func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPla Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash). Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now}) if err == nil && affected == 1 { - return true + return true, nil + } + if err != nil { + return false, err } valid, err := s.engine.ID(item.ID). Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash). Exist(&models.ProtocolCredential{}) - return err == nil && valid + return valid, err } diff --git a/backend/services/credentials/protocol.go b/backend/services/credentials/protocol.go index 6c54756..84d413b 100644 --- a/backend/services/credentials/protocol.go +++ b/backend/services/credentials/protocol.go @@ -9,8 +9,8 @@ import ( "time" "git.pcux.cn/xuans/Smail/models" - "git.pcux.cn/xuans/Smail/utils/password" "git.pcux.cn/xuans/Smail/utils/credential" + "git.pcux.cn/xuans/Smail/utils/password" ) const ( @@ -189,30 +189,34 @@ func (s *Service) RevokeProtocolCode(userID int, protocol string) error { if _, err := s.engine.Where("user_id = ? AND protocol = ? AND revoked_at IS NULL", userID, protocol). Cols("revoked_at", "code_hash", "code_ciphertext", "updated_at"). Update(&models.ProtocolCredential{RevokedAt: &now, CodeHash: "", CodeCiphertext: ""}); err != nil { - return fmt.Errorf("revoke protocol authorization code: %w", err) + return fmt.Errorf("revoke protocol authorization code: %w", err) } return nil } // VerifyProtocolCode 校验协议开关和授权码,不接受网页登录密码。 func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string) bool { + matched, _ := s.CheckProtocolCode(userID, protocol, plain) + return matched +} + +// CheckProtocolCode 保留计算过载错误,供需要区分重试与认证失败的调用方使用。 +func (s *Service) CheckProtocolCode(userID int, protocol string, plain string) (bool, error) { if s == nil || s.engine == nil || userID <= 0 || plain == "" || len([]byte(plain)) > 256 { - password.VerifyDummy(plain) - return false + return false, password.CheckDummy(plain) } protocol, err := NormalizeProtocol(protocol) if err != nil { - password.VerifyDummy(plain) - return false + return false, password.CheckDummy(plain) } var item models.ProtocolCredential has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item) if err != nil || !has || item.CodeHash == "" { - password.VerifyDummy(plain) - return false + return false, password.CheckDummy(plain) } - if !password.Verify(item.CodeHash, strings.TrimSpace(plain)) { - return false + matched, err := password.Check(item.CodeHash, strings.TrimSpace(plain)) + if err != nil || !matched { + return false, err } now := s.now() // 哈希计算期间可能发生关闭、撤销或换码,返回成功前必须再次原子确认凭据状态。 @@ -220,16 +224,16 @@ func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string) Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash). Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now}) if err != nil { - return false + return false, err } if affected == 1 { - return true + return true, nil } // MySQL 在时间字段未变化时可能报告零影响行,必须再次查询有效状态而不是误拒绝或直接放行。 valid, err := s.engine.ID(item.ID). Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash). Exist(&models.ProtocolCredential{}) - return err == nil && valid + return valid, err } func newProtocolCode() (string, error) { diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index adeb714..e878022 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -133,10 +133,9 @@ func (s *Service) VerifySecurityQuestion(userID int, answer string) (bool, error return false, fmt.Errorf("load security question: %w", err) } if !has { - password.VerifyDummy(answer) - return false, nil + return false, password.CheckDummy(answer) } - return password.Verify(item.AnswerHash, answer), nil + return password.Check(item.AnswerHash, answer) } // GenerateRecoveryCodes 撤销旧码并生成一组只返回一次的离线恢复码。 @@ -241,7 +240,11 @@ func (s *Service) consumeRecoveryCode(session recoveryCodeSession, userID int, c return fmt.Errorf("load recovery codes: %w", err) } for _, item := range items { - if !password.Verify(item.CodeHash, code) { + matched, err := password.Check(item.CodeHash, code) + if err != nil { + return err + } + if !matched { continue } now := s.now() diff --git a/backend/services/recovery/capacity_test.go b/backend/services/recovery/capacity_test.go new file mode 100644 index 0000000..cce8897 --- /dev/null +++ b/backend/services/recovery/capacity_test.go @@ -0,0 +1,47 @@ +package recovery + +import ( + "errors" + "testing" + + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/utils/password" +) + +func TestRecoveryBusyPreservesTicket(t *testing.T) { + service, _, _, user := newRecoveryFixture(t) + ticketID, err := service.issueTicket(user.ID, user.SessionVersion) + if err != nil { + t.Fatal(err) + } + release := passwordtest.Hold(t) + for index := 0; index < 12; index++ { + if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, password.ErrBusy) { + t.Fatalf("繁忙期间票据失效或返回错误:%v", err) + } + } + release() + if err := service.Complete(ticketID, "NewPass123"); err != nil { + t.Fatalf("繁忙恢复后不能使用原票据:%v", err) + } + if err := service.Complete(ticketID, "OtherPass12"); !errors.Is(err, ErrRecoveryExpired) { + t.Fatalf("成功后票据未保持一次性:%v", err) + } +} + +func TestRecoveryBusyDoesNotRestoreRevokedTicket(t *testing.T) { + service, _, _, user := newRecoveryFixture(t) + ticketID, err := service.issueTicket(user.ID, user.SessionVersion) + if err != nil { + t.Fatal(err) + } + release := passwordtest.Hold(t) + if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, password.ErrBusy) { + t.Fatal(err) + } + service.InvalidateUser(user.ID) + release() + if err := service.Complete(ticketID, "NewPass123"); !errors.Is(err, ErrRecoveryExpired) { + t.Fatalf("撤销的票据被恢复:%v", err) + } +} diff --git a/backend/services/recovery/email_step.go b/backend/services/recovery/email_step.go index c484853..3f59975 100644 --- a/backend/services/recovery/email_step.go +++ b/backend/services/recovery/email_step.go @@ -3,10 +3,12 @@ package recovery import ( "crypto/sha256" "crypto/subtle" + "errors" "strings" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/utils/password" ) const maxEmailContinuationAttempts = 5 @@ -93,10 +95,14 @@ func (s *Service) ContinueEmailWithContext(continuation, identifier, email, reco // 数据库和恢复码哈希验证不占用全局锁;同一凭证同时只能有一个请求验证。 invalidate := false + busy := false defer func() { s.mu.Lock() defer s.mu.Unlock() if s.emailPending[key] == pending { + if busy { + pending.Attempts-- + } if invalidate || pending.Attempts >= maxEmailContinuationAttempts { delete(s.emailPending, key) } else { @@ -115,6 +121,10 @@ func (s *Service) ContinueEmailWithContext(continuation, identifier, email, reco return "", ErrRecoveryDenied } if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil { + if errors.Is(err, password.ErrBusy) { + busy = true + return "", err + } return "", ErrRecoveryDenied } invalidate = true diff --git a/backend/services/recovery/public_link.go b/backend/services/recovery/public_link.go index d55165c..27ca4d5 100644 --- a/backend/services/recovery/public_link.go +++ b/backend/services/recovery/public_link.go @@ -1,6 +1,7 @@ package recovery import ( + "errors" "fmt" "strings" "time" @@ -203,6 +204,9 @@ func (s *Service) CompletePublicLink(token, newPassword, recoveryCode string) er }() if user.EffectiveRole() == access.RoleAdmin { if err := s.credentials.ConsumeRecoveryCodeWithSession(session, user.ID, recoveryCode); err != nil { + if errors.Is(err, password.ErrBusy) { + return err + } if s.recordPublicRecoveryCodeFailure(key, item) { consume = true } diff --git a/backend/services/recovery/service.go b/backend/services/recovery/service.go index 2975747..08f2645 100644 --- a/backend/services/recovery/service.go +++ b/backend/services/recovery/service.go @@ -63,6 +63,7 @@ type Service struct { } type ticket struct { + Busy bool UserID int SessionVersion int Method string @@ -249,6 +250,9 @@ func (s *Service) VerifyWithContext(challengeID, account, email, code, recoveryC return "", ErrRecoveryDenied } if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil { + if errors.Is(err, password.ErrBusy) { + return "", err + } return "", ErrRecoveryDenied } method = "email_admin" @@ -297,17 +301,26 @@ func (s *Service) VerifyOfflineWithContext(identifier, question, answer, recover } user, err := s.loadRecoverableUser(identifier) if err != nil { - password.VerifyDummy(answer) - password.VerifyDummy(recoveryCode) + if err := password.CheckDummy(answer); err != nil { + return "", err + } + if err := password.CheckDummy(recoveryCode); err != nil { + return "", err + } return "", ErrRecoveryDenied } storedQuestion, configured, questionErr := s.credentials.GetSecurityQuestion(user.ID) answerVerified, answerErr := s.credentials.VerifySecurityQuestion(user.ID, answer) + if errors.Is(answerErr, password.ErrBusy) { + return "", answerErr + } providedQuestionDigest := sha256.Sum256([]byte(strings.TrimSpace(question))) storedQuestionDigest := sha256.Sum256([]byte(strings.TrimSpace(storedQuestion))) questionVerified := configured && subtle.ConstantTimeCompare(providedQuestionDigest[:], storedQuestionDigest[:]) == 1 if questionErr != nil || answerErr != nil || !questionVerified || !answerVerified { - password.VerifyDummy(recoveryCode) + if err := password.CheckDummy(recoveryCode); err != nil { + return "", err + } return "", ErrRecoveryDenied } ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "offline", 0, 0, browserContext) @@ -316,6 +329,9 @@ func (s *Service) VerifyOfflineWithContext(identifier, question, answer, recover } if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil { s.invalidateTicket(ticketID) + if errors.Is(err, password.ErrBusy) { + return "", err + } return "", ErrRecoveryDenied } return ticketID, nil @@ -342,6 +358,10 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri s.mu.Unlock() return ErrRecoveryExpired } + if item.Busy { + s.mu.Unlock() + return ErrRecoveryDenied + } if item.HasBrowserContextDigest { providedDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext))) if strings.TrimSpace(browserContext) == "" || subtle.ConstantTimeCompare(providedDigest[:], item.BrowserContextDigest[:]) != 1 { @@ -349,11 +369,28 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri return ErrRecoveryDenied } } - delete(s.tickets, ticketDigest) + item.Busy = true + s.tickets[ticketDigest] = item s.mu.Unlock() + // 过载时保留同一张有效票据,已失效或已被撤销的票据不得恢复。 + retry := false + defer func() { + s.mu.Lock() + defer s.mu.Unlock() + if current, exists := s.tickets[ticketDigest]; exists && current == item { + if retry && s.now().Before(item.ExpiresAt) { + item.Busy = false + s.tickets[ticketDigest] = item + } else { + delete(s.tickets, ticketDigest) + } + } + }() + hash, err := auth.HashPassword(newPassword) if err != nil { + retry = errors.Is(err, password.ErrBusy) return err } defer clearString(&hash) diff --git a/backend/utils/password/capacity.go b/backend/utils/password/capacity.go new file mode 100644 index 0000000..94782ad --- /dev/null +++ b/backend/utils/password/capacity.go @@ -0,0 +1,17 @@ +package password + +import "errors" + +var ErrBusy = errors.New("password computation is busy") + +// 所有协议和恢复流程共享一个计算名额;满载立即拒绝,不积压等待请求。 +var computationSlot = make(chan struct{}, 1) + +func acquireComputation() (func(), error) { + select { + case computationSlot <- struct{}{}: + return func() { <-computationSlot }, nil + default: + return nil, ErrBusy + } +} diff --git a/backend/utils/password/capacity_test.go b/backend/utils/password/capacity_test.go new file mode 100644 index 0000000..f25bc00 --- /dev/null +++ b/backend/utils/password/capacity_test.go @@ -0,0 +1,105 @@ +package password_test + +import ( + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + + "git.pcux.cn/xuans/Smail/internal/passwordtest" + "git.pcux.cn/xuans/Smail/utils/password" +) + +func TestCapacityRejectsBurstAndReleasesAfterPanic(t *testing.T) { + encoded, err := password.Hash("StrongPass12") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(encoded, "$argon2id$v=19$m=65536,t=3,p=1$") { + t.Fatal("密码算法强度发生变化") + } + release := passwordtest.Hold(t) + var wg sync.WaitGroup + for index := 0; index < 64; index++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := password.Hash("StrongPass12"); !errors.Is(err, password.ErrBusy) { + t.Errorf("并发哈希未返回繁忙:%v", err) + } + if matched, err := password.Check(encoded, "StrongPass12"); matched || !errors.Is(err, password.ErrBusy) { + t.Errorf("并发校验未返回繁忙:%v %v", matched, err) + } + if err := password.CheckDummy("StrongPass12"); !errors.Is(err, password.ErrBusy) { + t.Errorf("模拟校验绕过计算名额:%v", err) + } + }() + } + wg.Wait() + release() + if matched, err := password.Check(encoded, "StrongPass12"); !matched || err != nil { + t.Fatalf("异常释放后不能继续认证:%v %v", matched, err) + } + if matched, err := password.Check(encoded, "WrongPass12"); matched || err != nil { + t.Fatalf("密码错误与服务繁忙未区分:%v %v", matched, err) + } +} + +func TestCapacityValidationDoesNotLeakSlot(t *testing.T) { + if _, err := password.Hash(""); !errors.Is(err, password.ErrInvalidLength) { + t.Fatal(err) + } + if matched, err := password.Check(strings.Repeat("x", 256), "StrongPass12"); matched || err != nil { + t.Fatal("非法哈希应被直接拒绝") + } + if err := password.CheckDummy("StrongPass12"); err != nil { + t.Fatal(err) + } + if _, err := password.Hash("StrongPass12"); err != nil { + t.Fatal(err) + } +} + +func TestCapacityRealConcurrentComputations(t *testing.T) { + encoded, err := password.Hash("StrongPass12") + if err != nil { + t.Fatal(err) + } + var succeeded, rejected atomic.Int32 + var wg sync.WaitGroup + start := make(chan struct{}) + for index := 0; index < 64; index++ { + wg.Add(1) + go func(mode int) { + defer wg.Done() + <-start + var err error + switch mode % 3 { + case 0: + _, err = password.Hash("StrongPass12") + case 1: + var matched bool + matched, err = password.Check(encoded, "StrongPass12") + if err == nil && !matched { + t.Error("真实并发校验密码不匹配") + } + default: + err = password.CheckDummy("StrongPass12") + } + if errors.Is(err, password.ErrBusy) { + rejected.Add(1) + } else if err == nil { + succeeded.Add(1) + } else { + t.Errorf("真实并发计算异常:%v", err) + } + }(index) + } + close(start) + wg.Wait() + if succeeded.Load() == 0 || rejected.Load() == 0 || succeeded.Load()+rejected.Load() != 64 { + t.Fatalf("并发保护未生效:成功=%d,繁忙=%d", succeeded.Load(), rejected.Load()) + } + t.Logf("64 路真实计算:成功=%d,繁忙=%d", succeeded.Load(), rejected.Load()) +} diff --git a/backend/utils/password/encode.go b/backend/utils/password/encode.go index ba2dde9..d8af773 100644 --- a/backend/utils/password/encode.go +++ b/backend/utils/password/encode.go @@ -43,6 +43,11 @@ func Hash(plain string) (string, error) { if plainSize < minSecretSize { return "", ErrInvalidLength } + release, err := acquireComputation() + if err != nil { + return "", err + } + defer release() salt := make([]byte, saltLength) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generate password salt: %w", err) @@ -82,28 +87,54 @@ func HashUserPassword(plain string) (string, error) { // Verify 校验 Argon2id 密码哈希,不接受旧算法格式。 func Verify(encoded, plain string) bool { + matched, _ := Check(encoded, plain) + return matched +} + +// Check 区分密码不匹配与计算资源繁忙,业务入口必须保留错误信息。 +func Check(encoded, plain string) (bool, error) { if len([]byte(plain)) < minSecretSize { - return false + return false, nil + } + if len(encoded) > maxHashSize { + return false, nil } parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != format || parts[2] != fmt.Sprintf("v=%d", version) || parts[3] != fmt.Sprintf("m=%d,t=%d,p=%d", memory, iterations, parallelism) { - return false + return false, nil } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil || len(salt) != saltLength { - return false + return false, nil } expected, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil || len(expected) != keyLength { - return false + return false, nil } + release, err := acquireComputation() + if err != nil { + return false, err + } + defer release() actual := argon2.IDKey([]byte(plain), salt, iterations, memory, parallelism, keyLength) - return subtle.ConstantTimeCompare(actual, expected) == 1 + return subtle.ConstantTimeCompare(actual, expected) == 1, nil } func VerifyDummy(plain string) bool { + _ = CheckDummy(plain) + return false +} + +// CheckDummy 为不存在的凭据执行同等强度计算,并传递过载状态。 +func CheckDummy(plain string) error { + release, err := acquireComputation() + if err != nil { + return err + } + defer release() salt := [saltLength]byte{0x53, 0x6d, 0x61, 0x69, 0x6c, 0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x64, 0x75, 0x6d, 0x6d, 0x79} expected := [keyLength]byte{0x91, 0xa4, 0x65, 0x82, 0x31, 0x07, 0x44, 0xc8, 0x7a, 0x22, 0xe1, 0x59, 0x3c, 0x5b, 0x8d, 0xf1, 0xd4, 0x3a, 0x68, 0x9c, 0xee, 0x20, 0x57, 0x61, 0x45, 0x3a, 0x71, 0x10, 0x5b, 0x26, 0xb6, 0xa0} actual := argon2.IDKey([]byte(plain), salt[:], iterations, memory, parallelism, keyLength) - return subtle.ConstantTimeCompare(actual, expected[:]) == 1 + _ = subtle.ConstantTimeCompare(actual, expected[:]) + return nil } diff --git a/docs/生产整改验收清单.md b/docs/生产整改验收清单.md index 34f5554..4c6842c 100644 --- a/docs/生产整改验收清单.md +++ b/docs/生产整改验收清单.md @@ -27,6 +27,7 @@ | PROD-013 | P0 | 共享主机资源隔离与容量 | [!] | 首轮测试期间发生全机 OOM,MySQL 被终止后自动重启;必须补齐资源规划 | | PROD-014 | P1 | 投递结果写库恢复 | [✓] | 12 项远程测试及源码复审通过;持久故障后的进程退出仍需人工核对,不代表生产故障演练通过 | | PROD-015 | P1 | 跨平台证书路径与插件测试 | [✓] | Linux 拒绝异平台路径误解析;保持证书校验、托管导入和插件目录权限要求 | +| PROD-016 | P0 | 密码计算全局并发和过载处理 | [~] | 保留 Argon2id 64 MiB 参数,增加单计算名额、立即拒绝和可重试错误;远程回归待执行 | 状态含义:`[ ]` 未开始,`[~]` 进行中,`[x]` 已完成待复审,`[✓]` 指定范围复审通过,`[!]` 暂缓或存在阻塞。