diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 3e82721..484ea76 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -12,14 +12,12 @@ var ( ErrUserNotFound = errors.New("用户不存在") ErrUserAlreadyExists = errors.New("用户已存在") ErrEmailAlreadyExists = errors.New("邮箱已被注册") - ErrInvalidPassword = errors.New("密码错误") ErrAccountDisabled = errors.New("账号已被禁用") // 认证相关错误 - ErrUnauthorized = errors.New("未授权") - ErrInvalidToken = errors.New("无效的令牌") - ErrTokenExpired = errors.New("令牌已过期") - ErrInvalidSignature = errors.New("签名验证失败") + ErrUnauthorized = errors.New("未授权") + ErrInvalidToken = errors.New("无效的令牌") + ErrTokenExpired = errors.New("令牌已过期") // 档案相关错误 ErrProfileNotFound = errors.New("档案不存在") @@ -32,7 +30,6 @@ var ( ErrTextureExists = errors.New("该材质已存在") ErrTextureLimitReached = errors.New("已达材质数量上限") ErrTextureNoPermission = errors.New("无权操作此材质") - ErrInvalidTextureType = errors.New("无效的材质类型") // 验证码相关错误 ErrInvalidVerificationCode = errors.New("验证码错误或已过期") @@ -54,15 +51,11 @@ var ( ErrSessionNotFound = errors.New("会话不存在或已过期") ErrSessionMismatch = errors.New("会话验证失败") ErrUsernameMismatch = errors.New("用户名不匹配") + ErrUsernameRequired = errors.New("用户名不能为空") ErrIPMismatch = errors.New("IP地址不匹配") ErrInvalidAccessToken = errors.New("访问令牌无效") ErrProfileMismatch = errors.New("selectedProfile与Token不匹配") ErrUUIDRequired = errors.New("UUID不能为空") - ErrCertificateGenerate = errors.New("生成证书失败") - - // Yggdrasil协议标准错误 - ErrYggForbiddenOperation = errors.New("ForbiddenOperationException") - ErrYggIllegalArgument = errors.New("IllegalArgumentException") // 通用错误 ErrBadRequest = errors.New("请求参数错误") diff --git a/internal/handler/captcha_handler.go b/internal/handler/captcha_handler.go index 3d0d2cd..52638ef 100644 --- a/internal/handler/captcha_handler.go +++ b/internal/handler/captcha_handler.go @@ -35,7 +35,7 @@ type CaptchaVerifyRequest struct { // @Tags captcha // @Accept json // @Produce json -// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, data: {masterImage, tileImage, captchaId, y}}" +// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, message: string, data: {masterImage, tileImage, captchaId, y}}" // @Failure 500 {object} map[string]interface{} "生成失败" // @Router /api/v1/captcha/generate [get] func (h *CaptchaHandler) Generate(c *gin.Context) { @@ -43,14 +43,15 @@ func (h *CaptchaHandler) Generate(c *gin.Context) { if err != nil { h.logger.Error("生成验证码失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": "生成验证码失败", + "code": 500, + "message": "生成验证码失败", }) return } c.JSON(http.StatusOK, gin.H{ - "code": 200, + "code": 200, + "message": "操作成功", "data": gin.H{ "masterImage": masterImg, "tileImage": tileImg, @@ -67,15 +68,15 @@ func (h *CaptchaHandler) Generate(c *gin.Context) { // @Accept json // @Produce json // @Param request body CaptchaVerifyRequest true "验证请求" -// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, msg: string}" +// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, message: string}" // @Failure 400 {object} map[string]interface{} "参数错误" // @Router /api/v1/captcha/verify [post] func (h *CaptchaHandler) Verify(c *gin.Context) { var req CaptchaVerifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "msg": "参数错误: " + err.Error(), + "code": 400, + "message": "参数错误: " + err.Error(), }) return } @@ -87,21 +88,21 @@ func (h *CaptchaHandler) Verify(c *gin.Context) { zap.Error(err), ) c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "msg": "验证失败", + "code": 500, + "message": "验证失败", }) return } if valid { c.JSON(http.StatusOK, gin.H{ - "code": 200, - "msg": "验证成功", + "code": 200, + "message": "验证成功", }) } else { c.JSON(http.StatusOK, gin.H{ - "code": 400, - "msg": "验证失败,请重试", + "code": 400, + "message": "验证失败,请重试", }) } } diff --git a/internal/handler/yggdrasil_handler.go b/internal/handler/yggdrasil_handler.go index 4b177a0..6a4e201 100644 --- a/internal/handler/yggdrasil_handler.go +++ b/internal/handler/yggdrasil_handler.go @@ -1,8 +1,6 @@ package handler import ( - "bytes" - "io" "net/http" "regexp" @@ -17,66 +15,18 @@ import ( // 常量定义 const ( - ErrInternalServer = "服务器内部错误" - // 错误类型 - ErrInvalidEmailFormat = "邮箱格式不正确" - ErrInvalidPassword = "密码必须至少包含8个字符,只能包含字母、数字和特殊字符" - ErrWrongPassword = "密码错误" - ErrUserNotMatch = "用户不匹配" - - // 错误消息 - ErrInvalidRequest = "请求格式无效" - ErrJoinServerFailed = "加入服务器失败" - ErrServerIDRequired = "服务器ID不能为空" - ErrUsernameRequired = "用户名不能为空" - ErrSessionVerifyFailed = "会话验证失败" - ErrProfileNotFound = "未找到用户配置文件" - ErrInvalidParams = "无效的请求参数" - ErrEmptyUserID = "用户ID为空" - ErrUnauthorized = "无权操作此配置文件" - ErrGetProfileService = "获取配置文件服务失败" - - // 成功信息 - SuccessProfileCreated = "创建成功" - MsgRegisterSuccess = "注册成功" - - // 错误消息 - ErrGetProfile = "获取配置文件失败" - ErrGetTextureService = "获取材质服务失败" - ErrInvalidContentType = "无效的请求内容类型" - ErrParseMultipartForm = "解析多部分表单失败" - ErrGetFileFromForm = "从表单获取文件失败" - ErrInvalidFileType = "无效的文件类型,仅支持PNG图片" - ErrSaveTexture = "保存材质失败" - ErrSetTexture = "设置材质失败" - ErrGetTexture = "获取材质失败" - // 内存限制 MaxMultipartMemory = 32 << 20 // 32 MB // 材质类型 TextureTypeSkin = "SKIN" TextureTypeCape = "CAPE" - - // 内容类型 - ContentTypePNG = "image/png" - ContentTypeMultipart = "multipart/form-data" - - // 表单参数 - FormKeyModel = "model" - FormKeyFile = "file" - - // 元数据键 - MetaKeyModel = "model" ) // 正则表达式 var ( // 邮箱正则表达式 emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) - - // 密码强度正则表达式(最少8位,只允许字母、数字和特定特殊字符) - passwordRegex = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*]{8,}$`) ) // 请求结构体 @@ -139,22 +89,6 @@ type ( } ) -// APIResponse API响应 -type APIResponse struct { - Status int `json:"status"` - Data interface{} `json:"data"` - Error interface{} `json:"error"` -} - -// standardResponse 生成标准响应 -func standardResponse(c *gin.Context, status int, data interface{}, err interface{}) { - c.JSON(status, APIResponse{ - Status: status, - Data: data, - Error: err, - }) -} - // YggdrasilHandler Yggdrasil API处理器 type YggdrasilHandler struct { container *container.Container @@ -180,16 +114,8 @@ func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler { // @Failure 403 {object} map[string]string "认证失败" // @Router /api/yggdrasil/authserver/authenticate [post] func (h *YggdrasilHandler) Authenticate(c *gin.Context) { - rawData, err := io.ReadAll(c.Request.Body) - if err != nil { - h.logger.Error("读取请求体失败", zap.Error(err)) - c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"}) - return - } - c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData)) - var request AuthenticateRequest - if err = c.ShouldBindJSON(&request); err != nil { + if err := c.ShouldBindJSON(&request); err != nil { h.logger.Error("解析认证请求失败", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -198,8 +124,10 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) { var userId int64 var profile *model.Profile var UUID string + var err error if emailRegex.MatchString(request.Identifier) { + // 邮箱登录:UUID 留空,后续 TokenService.Create 会自动选择该用户的角色 userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier) } else { profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier) @@ -507,7 +435,7 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) { if !emailRegex.MatchString(request.Email) { h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email)) - c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidEmailFormat}) + c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"}) return } @@ -579,8 +507,8 @@ func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) { // @Produce json // @Param request body JoinServerRequest true "加入请求" // @Success 204 "加入成功" -// @Failure 400 {object} APIResponse "参数错误" -// @Failure 500 {object} APIResponse "服务器错误" +// @Failure 400 {object} map[string]string "参数错误" +// @Failure 500 {object} map[string]string "服务器错误" // @Router /api/yggdrasil/sessionserver/session/minecraft/join [post] func (h *YggdrasilHandler) JoinServer(c *gin.Context) { var request JoinServerRequest @@ -643,14 +571,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { serverID, exists := c.GetQuery("serverId") if !exists || serverID == "" { h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP)) - standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired) + c.Status(http.StatusNoContent) return } username, exists := c.GetQuery("username") if !exists || username == "" { h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP)) - standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired) + c.Status(http.StatusNoContent) return } @@ -667,14 +595,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { zap.String("username", username), zap.String("ip", clientIP), ) - standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed) + c.Status(http.StatusNoContent) return } profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username) if err != nil { h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username)) - standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound) + c.Status(http.StatusNoContent) return } @@ -683,7 +611,7 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { zap.String("username", username), zap.String("uuid", profile.UUID), ) - c.JSON(200, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)) + c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)) } // GetProfilesByName 批量获取配置文件 @@ -694,14 +622,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) { // @Produce json // @Param request body []string true "用户名列表" // @Success 200 {array} model.Profile "档案列表" -// @Failure 400 {object} APIResponse "参数错误" +// @Failure 400 {object} map[string]string "参数错误" // @Router /api/yggdrasil/api/profiles/minecraft [post] func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) { var names []string if err := c.ShouldBindJSON(&names); err != nil { h.logger.Error("解析名称数组请求失败", zap.Error(err)) - standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams) + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"}) return } @@ -723,7 +651,7 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {object} map[string]interface{} "元数据" -// @Failure 500 {object} APIResponse "服务器错误" +// @Failure 500 {object} map[string]string "服务器错误" // @Router /api/yggdrasil [get] func (h *YggdrasilHandler) GetMetaData(c *gin.Context) { meta := gin.H{ @@ -765,7 +693,7 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) { // @Accept json // @Produce json // @Param Authorization header string true "Bearer {token}" -// @Success 200 {object} map[string]interface{} "证书信息" +// @Success 200 {object} service.PlayerCertificate "证书信息" // @Failure 401 {object} errors.YggdrasilErrorResponse "未授权" // @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效" // @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误" @@ -805,10 +733,10 @@ func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) { } uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID) - if uuid == "" { + if err != nil || uuid == "" { h.logger.Error("获取玩家UUID失败", zap.Error(err)) - c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( - "ForbiddenOperationException", + c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse( + "Unauthorized", errors.YggErrInvalidToken, "", )) diff --git a/internal/model/profile.go b/internal/model/profile.go index 23ddc9e..2e80024 100644 --- a/internal/model/profile.go +++ b/internal/model/profile.go @@ -7,15 +7,22 @@ import ( // Profile Minecraft 档案模型 // @Description Minecraft角色档案数据模型 type Profile struct { - UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` - UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"` - Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 - SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"` - CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"` - RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端 - LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` + UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"` + Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 + SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"` + CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"` + // RSA 私钥不返回给前端 + RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` + // 玩家证书完整密钥对(持久化以避免每次请求都重新生成 RSA 4096 密钥) + RSAPublicKey string `gorm:"column:rsa_public_key;type:text" json:"-"` + PublicKeySignature string `gorm:"column:public_key_signature;type:text" json:"-"` + PublicKeySignatureV2 string `gorm:"column:public_key_signature_v2;type:text" json:"-"` + KeyExpiresAt *time.Time `gorm:"column:key_expires_at;type:timestamp" json:"-"` + KeyRefreshAt *time.Time `gorm:"column:key_refresh_at;type:timestamp" json:"-"` + LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` // 关联 User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"` diff --git a/internal/repository/profile_repository.go b/internal/repository/profile_repository.go index fcc6cc0..6cad065 100644 --- a/internal/repository/profile_repository.go +++ b/internal/repository/profile_repository.go @@ -132,7 +132,7 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (* var profile model.Profile result := r.db.WithContext(ctx). - Select("rsa_private_key"). + Select("rsa_private_key", "rsa_public_key", "public_key_signature", "public_key_signature_v2", "key_expires_at", "key_refresh_at"). Where("uuid = ?", profileId). First(&profile) @@ -143,9 +143,19 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (* return nil, fmt.Errorf("获取key pair失败: %w", result.Error) } - return &model.KeyPair{ - PrivateKey: profile.RSAPrivateKey, - }, nil + keyPair := &model.KeyPair{ + PrivateKey: profile.RSAPrivateKey, + PublicKey: profile.RSAPublicKey, + PublicKeySignature: profile.PublicKeySignature, + PublicKeySignatureV2: profile.PublicKeySignatureV2, + } + if profile.KeyExpiresAt != nil { + keyPair.Expiration = *profile.KeyExpiresAt + } + if profile.KeyRefreshAt != nil { + keyPair.Refresh = *profile.KeyRefreshAt + } + return keyPair, nil } func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error { @@ -159,7 +169,14 @@ func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Profile{}). Where("uuid = ?", profileId). - Update("rsa_private_key", keyPair.PrivateKey) + Updates(map[string]interface{}{ + "rsa_private_key": keyPair.PrivateKey, + "rsa_public_key": keyPair.PublicKey, + "public_key_signature": keyPair.PublicKeySignature, + "public_key_signature_v2": keyPair.PublicKeySignatureV2, + "key_expires_at": keyPair.Expiration, + "key_refresh_at": keyPair.Refresh, + }) if result.Error != nil { return fmt.Errorf("更新 keyPair 失败: %w", result.Error) diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 036b960..9cb638a 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -134,7 +134,7 @@ type YggdrasilService interface { SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} // 证书 - GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) + GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) GetPublicKey(ctx context.Context) (string, error) } diff --git a/internal/service/signature_service.go b/internal/service/signature_service.go index 6b5ec4a..fbe4343 100644 --- a/internal/service/signature_service.go +++ b/internal/service/signature_service.go @@ -12,7 +12,7 @@ import ( "encoding/pem" "fmt" "strconv" - "strings" + "sync" "time" "carrotskin/internal/model" @@ -38,6 +38,11 @@ type SignatureService struct { profileRepo repository.ProfileRepository redis *redis.Client logger *zap.Logger + + // 缓存已解析的根密钥对,避免每次签名都重复 PEM 解析(PKCS1 解析开销较大) + rootKeyMu sync.RWMutex + rootPrivateKey *rsa.PrivateKey + rootPublicKeyPEM string } // NewSignatureService 创建SignatureService实例 @@ -91,74 +96,77 @@ func (s *SignatureService) NewKeyPair(ctx context.Context) (*model.KeyPair, erro return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err) } - // 构造签名消息 + // 构造签名消息(PEM 公钥 + 过期时间戳) expiresAtMillis := expiration.UnixMilli() - message := []byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10)) + message := make([]byte, 0, len(publicKeyPEM)+20) + message = append(message, publicKeyPEM...) + message = strconv.AppendInt(message, expiresAtMillis, 10) // 使用SHA1withRSA签名 - hashed := sha1.Sum(message) - signature, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashed[:]) + publicKeySignature, err := signWithRootKey(yggPrivateKey, message) if err != nil { return nil, fmt.Errorf("签名失败: %w", err) } - publicKeySignature := base64.StdEncoding.EncodeToString(signature) - - // 构造V2签名消息(DER编码) - publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) - if err != nil { - return nil, fmt.Errorf("DER编码公钥失败: %w", err) - } // V2签名:timestamp (8 bytes, big endian) + publicKey (DER) - messageV2 := make([]byte, 8+len(publicKeyDER)) + messageV2 := make([]byte, 8+len(publicKeyBytes)) binary.BigEndian.PutUint64(messageV2[0:8], uint64(expiresAtMillis)) - copy(messageV2[8:], publicKeyDER) + copy(messageV2[8:], publicKeyBytes) - hashedV2 := sha1.Sum(messageV2) - signatureV2, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashedV2[:]) + publicKeySignatureV2, err := signWithRootKey(yggPrivateKey, messageV2) if err != nil { return nil, fmt.Errorf("V2签名失败: %w", err) } - publicKeySignatureV2 := base64.StdEncoding.EncodeToString(signatureV2) return &model.KeyPair{ PrivateKey: string(privateKeyPEM), PublicKey: string(publicKeyPEM), - PublicKeySignature: publicKeySignature, - PublicKeySignatureV2: publicKeySignatureV2, + PublicKeySignature: base64.StdEncoding.EncodeToString(publicKeySignature), + PublicKeySignatureV2: base64.StdEncoding.EncodeToString(publicKeySignatureV2), YggdrasilPublicKey: yggPublicKey, Expiration: expiration, Refresh: refresh, }, nil } +// signWithRootKey 使用根密钥对消息做 SHA1withRSA 签名 +func signWithRootKey(privateKey *rsa.PrivateKey, message []byte) ([]byte, error) { + hashed := sha1.Sum(message) + return rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:]) +} + // GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对 +// 优先使用进程内缓存的已解析私钥;缓存未命中时从Redis加载并解析,再缓存。 func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) { - // 尝试从Redis获取密钥 - publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey) - if err == nil && publicKeyPEM != "" { - privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey) - if err == nil && privateKeyPEM != "" { - // 检查密钥是否过期 - expStr, err := s.redis.Get(ctx, KeyExpirationRedisKey) - if err == nil && expStr != "" { - expTime, err := time.Parse(time.RFC3339, expStr) - if err == nil && time.Now().Before(expTime) { - // 密钥有效,解析私钥 - block, _ := pem.Decode([]byte(privateKeyPEM)) - if block != nil { - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err == nil { - s.logger.Info("从Redis加载Yggdrasil根密钥") - return publicKeyPEM, privateKey, nil - } - } - } - } - } + // 1. 读缓存(快路径) + s.rootKeyMu.RLock() + if s.rootPrivateKey != nil && s.isRootKeyValid() { + pub := s.rootPublicKeyPEM + priv := s.rootPrivateKey + s.rootKeyMu.RUnlock() + return pub, priv, nil + } + s.rootKeyMu.RUnlock() + + // 2. 缓存未命中,加锁准备从 Redis 加载/生成 + s.rootKeyMu.Lock() + defer s.rootKeyMu.Unlock() + + // 双重检查:可能在等待锁期间已被其他 goroutine 填充 + if s.rootPrivateKey != nil && s.isRootKeyValidLocked() { + return s.rootPublicKeyPEM, s.rootPrivateKey, nil } - // 生成新的根密钥对 + // 尝试从Redis获取密钥(MGet 一次往返拿三个字段,减少网络往返) + pub, priv, err := s.loadRootKeyFromRedis(ctx) + if err == nil && priv != nil { + s.rootPrivateKey = priv + s.rootPublicKeyPEM = pub + s.logger.Info("从Redis加载Yggdrasil根密钥") + return pub, priv, nil + } + + // Redis 没有,生成新的根密钥对 s.logger.Info("生成新的Yggdrasil根密钥对") privateKey, err := rsa.GenerateKey(rand.Reader, KeySize) if err != nil { @@ -177,7 +185,7 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (str if err != nil { return "", nil, fmt.Errorf("编码公钥失败: %w", err) } - publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{ Type: "PUBLIC KEY", Bytes: publicKeyBytes, })) @@ -196,9 +204,57 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (str s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err)) } + // 缓存已解析的私钥与公钥PEM + s.rootPrivateKey = privateKey + s.rootPublicKeyPEM = publicKeyPEM return publicKeyPEM, privateKey, nil } +// loadRootKeyFromRedis 从Redis加载根密钥,使用 MGet 单次往返获取三字段 +func (s *SignatureService) loadRootKeyFromRedis(ctx context.Context) (string, *rsa.PrivateKey, error) { + results, err := s.redis.MGet(ctx, PublicKeyRedisKey, PrivateKeyRedisKey, KeyExpirationRedisKey).Result() + if err != nil { + return "", nil, err + } + if len(results) < 3 { + return "", nil, fmt.Errorf("MGet 返回字段数不足") + } + + publicKeyPEM, _ := results[0].(string) + privateKeyPEM, _ := results[1].(string) + expStr, _ := results[2].(string) + + if publicKeyPEM == "" || privateKeyPEM == "" || expStr == "" { + return "", nil, fmt.Errorf("Redis中密钥字段不完整") + } + + expTime, err := time.Parse(time.RFC3339, expStr) + if err != nil || !time.Now().Before(expTime) { + return "", nil, fmt.Errorf("密钥已过期或过期时间解析失败") + } + + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", nil, fmt.Errorf("解析PEM私钥失败") + } + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", nil, fmt.Errorf("解析RSA私钥失败: %w", err) + } + return publicKeyPEM, privateKey, nil +} + +// isRootKeyValid 在持有读锁时判断缓存密钥是否过期。 +// 注意:缓存的私钥本身不带过期时间,过期信息存于 Redis(由 loadRootKeyFromRedis 校验)。 +// 一旦加载成功即认为在进程生命周期内有效,由 Redis 过期触发重新生成。 +func (s *SignatureService) isRootKeyValid() bool { + return s.isRootKeyValidLocked() +} + +func (s *SignatureService) isRootKeyValidLocked() bool { + return s.rootPrivateKey != nil && s.rootPublicKeyPEM != "" +} + // GetPublicKeyFromRedis 从Redis获取公钥 func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) { publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey) @@ -216,81 +272,16 @@ func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, e } // SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串 +// 使用缓存的已解析根私钥;若缓存为空则触发加载/生成。 func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) { - - // 从Redis获取私钥 - privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey) - if err != nil || privateKeyPEM == "" { - // 如果没有私钥,创建新的密钥对 - _, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx) - if err != nil { - return "", fmt.Errorf("获取私钥失败: %w", err) - } - // 使用新生成的私钥签名 - hashed := sha1.Sum([]byte(data)) - signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:]) - if err != nil { - return "", fmt.Errorf("签名失败: %w", err) - } - return base64.StdEncoding.EncodeToString(signature), nil - } - - // 解析PEM格式的私钥 - block, _ := pem.Decode([]byte(privateKeyPEM)) - if block == nil { - return "", fmt.Errorf("解析PEM私钥失败") - } - - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + _, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx) if err != nil { - return "", fmt.Errorf("解析RSA私钥失败: %w", err) + return "", fmt.Errorf("获取签名私钥失败: %w", err) } - // 签名 - hashed := sha1.Sum([]byte(data)) - signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:]) + signature, err := signWithRootKey(privateKey, []byte(data)) if err != nil { return "", fmt.Errorf("签名失败: %w", err) } - - return base64.StdEncoding.EncodeToString(signature), nil -} - -// FormatPublicKey 格式化公钥为单行格式(去除PEM头尾和换行符) -func FormatPublicKey(publicKeyPEM string) string { - // 移除PEM格式的头尾 - lines := strings.Split(publicKeyPEM, "\n") - var keyLines []string - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed != "" && - !strings.HasPrefix(trimmed, "-----BEGIN") && - !strings.HasPrefix(trimmed, "-----END") { - keyLines = append(keyLines, trimmed) - } - } - return strings.Join(keyLines, "") -} - -// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串 -func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) { - // 解析PEM格式的私钥 - block, _ := pem.Decode([]byte(privateKeyPEM)) - if block == nil { - return "", fmt.Errorf("解析PEM私钥失败") - } - - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return "", fmt.Errorf("解析RSA私钥失败: %w", err) - } - - // 签名 - hashed := sha1.Sum([]byte(data)) - signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:]) - if err != nil { - return "", fmt.Errorf("签名失败: %w", err) - } - return base64.StdEncoding.EncodeToString(signature), nil } diff --git a/internal/service/signature_service_test.go b/internal/service/signature_service_test.go new file mode 100644 index 0000000..771de95 --- /dev/null +++ b/internal/service/signature_service_test.go @@ -0,0 +1,403 @@ +package service + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "strconv" + "strings" + "sync" + "testing" + + pkgRedis "carrotskin/pkg/redis" + + miniredis "github.com/alicebob/miniredis/v2" + goRedis "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// newTestSignatureService 构造一个基于 miniredis 的 SignatureService,用于测试。 +// 返回服务实例与清理函数。 +func newTestSignatureService(t *testing.T) (*SignatureService, *miniredis.Miniredis, func()) { + t.Helper() + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("启动 miniredis 失败: %v", err) + } + + rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()}) + client := &pkgRedis.Client{Client: rdb} + + svc := NewSignatureService(nil, client, zap.NewNop()) + cleanup := func() { + _ = rdb.Close() + mr.Close() + } + return svc, mr, cleanup +} + +// TestSignatureService_SignString_Deterministic 验证 SHA1withRSA 签名的确定性: +// 相同 data + 相同根密钥应产生完全相同的签名(PKCS#1v1.5 非随机化)。 +// 这也是本次重构(缓存已解析私钥)后输出与重构前一致的核心保证。 +func TestSignatureService_SignString_Deterministic(t *testing.T) { + svc, _, cleanup := newTestSignatureService(t) + defer cleanup() + ctx := context.Background() + + // 首次签名会生成并缓存根密钥 + sig1, err := svc.SignStringWithSHA1withRSA(ctx, "test-data-123") + if err != nil { + t.Fatalf("首次签名失败: %v", err) + } + + // 第二次签名应命中缓存的已解析私钥,输出必须与首次完全一致 + sig2, err := svc.SignStringWithSHA1withRSA(ctx, "test-data-123") + if err != nil { + t.Fatalf("第二次签名失败: %v", err) + } + + if sig1 != sig2 { + t.Fatalf("签名不确定:sig1=%q sig2=%q", sig1, sig2) + } + + // 不同数据应产生不同签名 + sig3, err := svc.SignStringWithSHA1withRSA(ctx, "different-data") + if err != nil { + t.Fatalf("不同数据签名失败: %v", err) + } + if sig1 == sig3 { + t.Fatal("不同数据的签名不应相同") + } +} + +// TestSignatureService_SignString_MatchesReference 验证重构后的签名与 +// 重构前的参考实现(直接从 Redis 读取私钥 PEM 并解析、逐次签名)产生相同字节序列。 +// 这是签名一致性的回归测试:只要 data 与根私钥相同,新旧实现输出一致。 +func TestSignatureService_SignString_MatchesReference(t *testing.T) { + svc, mr, cleanup := newTestSignatureService(t) + defer cleanup() + ctx := context.Background() + + // 触发根密钥生成并写入 miniredis + if _, err := svc.SignStringWithSHA1withRSA(ctx, "consistency-check"); err != nil { + t.Fatalf("触发根密钥生成失败: %v", err) + } + + // 从 miniredis 读取根私钥 PEM,模拟重构前每次签名都重新解析的逻辑 + privPEM, err := mr.Get(PrivateKeyRedisKey) + if err != nil { + t.Fatalf("读取根私钥失败: %v", err) + } + + cases := []string{ + "", + "a", + strings.Repeat("x", 1024), + "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImFiYzEyIiwicHJvZmlsZU5hbWUiOiJUZXN0In0=", + } + for _, data := range cases { + // 重构后实现 + got, err := svc.SignStringWithSHA1withRSA(ctx, data) + if err != nil { + t.Fatalf("SignStringWithSHA1withRSA(%q) 失败: %v", data, err) + } + + // 参考实现(重构前逻辑):pem.Decode + ParsePKCS1PrivateKey + SignPKCS1v15 + block, _ := pem.Decode([]byte(privPEM)) + if block == nil { + t.Fatalf("参考实现解析 PEM 失败") + } + priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + t.Fatalf("参考实现解析 RSA 私钥失败: %v", err) + } + hashed := sha1.Sum([]byte(data)) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA1, hashed[:]) + if err != nil { + t.Fatalf("参考实现签名失败: %v", err) + } + want := base64.StdEncoding.EncodeToString(sig) + + if got != want { + t.Fatalf("签名与参考实现不一致 data=%q\n got=%q\nwant=%q", data, got, want) + } + } +} + +// TestSignatureService_RootKeyCachedAcrossReloads 验证: +// 同一进程内多次 GetOrCreateYggdrasilKeyPair 返回同一已解析私钥实例(缓存生效), +// 且不重复访问 Redis。 +func TestSignatureService_RootKeyCachedAcrossReloads(t *testing.T) { + svc, mr, cleanup := newTestSignatureService(t) + defer cleanup() + ctx := context.Background() + + // 首次生成 + pub1, priv1, err := svc.GetOrCreateYggdrasilKeyPair(ctx) + if err != nil { + t.Fatalf("首次 GetOrCreate 失败: %v", err) + } + + // 第二次应命中缓存,返回同一私钥指针 + pub2, priv2, err := svc.GetOrCreateYggdrasilKeyPair(ctx) + if err != nil { + t.Fatalf("第二次 GetOrCreate 失败: %v", err) + } + if priv1 != priv2 { + t.Fatal("缓存未命中:返回了不同的私钥实例") + } + if pub1 != pub2 { + t.Fatal("公钥 PEM 不一致") + } + + // Redis 仅应被写入一次(首次生成);记录当前 key 数量,再次调用不应增加 + keysBefore := len(mr.Keys()) + _, _, _ = svc.GetOrCreateYggdrasilKeyPair(ctx) + if got := len(mr.Keys()); got != keysBefore { + t.Fatalf("缓存命中后仍写入 Redis:keys before=%d after=%d", keysBefore, got) + } +} + +// TestSignatureService_NewKeyPair_SignatureVerifies 验证 NewKeyPair 生成的 +// publicKeySignature / publicKeySignatureV2 能用根公钥正确验签, +// 且消息构造与重构前格式一致(PEM公钥+时间戳;DER公钥+时间戳)。 +func TestSignatureService_NewKeyPair_SignatureVerifies(t *testing.T) { + svc, mr, cleanup := newTestSignatureService(t) + defer cleanup() + ctx := context.Background() + + kp, err := svc.NewKeyPair(ctx) + if err != nil { + t.Fatalf("NewKeyPair 失败: %v", err) + } + + // 取根公钥用于验签 + rootPubPEM, err := mr.Get(PublicKeyRedisKey) + if err != nil { + t.Fatalf("读取根公钥失败: %v", err) + } + block, _ := pem.Decode([]byte(rootPubPEM)) + if block == nil { + t.Fatal("解析根公钥 PEM 失败") + } + rootPub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatalf("解析根公钥失败: %v", err) + } + rsaRootPub, ok := rootPub.(*rsa.PublicKey) + if !ok { + t.Fatal("根公钥不是 RSA 公钥") + } + + expiresAtMillis := kp.Expiration.UnixMilli() + + // V1 消息:重构前为 string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10) + // 重构后用 append 拼接,字节序列应完全一致 + pubPEM := []byte(kp.PublicKey) + v1Message := make([]byte, 0, len(pubPEM)+20) + v1Message = append(v1Message, pubPEM...) + v1Message = strconv.AppendInt(v1Message, expiresAtMillis, 10) + + sig1, err := base64.StdEncoding.DecodeString(kp.PublicKeySignature) + if err != nil { + t.Fatalf("解码 publicKeySignature 失败: %v", err) + } + hashed1 := sha1.Sum(v1Message) + if err := rsa.VerifyPKCS1v15(rsaRootPub, crypto.SHA1, hashed1[:], sig1); err != nil { + t.Fatalf("V1 签名验签失败: %v", err) + } + + // V2 消息:重构前为 timestamp(8 BE) + 玩家公钥 DER + // 玩家公钥 DER 来自 kp.PublicKey 的 PEM 解码 + playerBlock, _ := pem.Decode([]byte(kp.PublicKey)) + if playerBlock == nil { + t.Fatal("解析玩家公钥 PEM 失败") + } + playerDER := playerBlock.Bytes // PKIX 编码的 SubjectPublicKeyInfo DER + v2Message := make([]byte, 8+len(playerDER)) + binary.BigEndian.PutUint64(v2Message[0:8], uint64(expiresAtMillis)) + copy(v2Message[8:], playerDER) + + sig2, err := base64.StdEncoding.DecodeString(kp.PublicKeySignatureV2) + if err != nil { + t.Fatalf("解码 publicKeySignatureV2 失败: %v", err) + } + hashed2 := sha1.Sum(v2Message) + if err := rsa.VerifyPKCS1v15(rsaRootPub, crypto.SHA1, hashed2[:], sig2); err != nil { + t.Fatalf("V2 签名验签失败: %v", err) + } +} + +// TestSignatureService_NewKeyPair_V1MessageBytes 验证 V1 消息字节构造与重构前完全一致。 +// 重构前:[]byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10)) +// 重构后:append(publicKeyPEM...); strconv.AppendInt(msg, expiresAtMillis, 10) +func TestSignatureService_NewKeyPair_V1MessageBytes(t *testing.T) { + // 构造一个固定的 PEM 公钥与时间戳,对比两种构造方式的字节 + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: []byte("dummy-der-bytes"), + }) + expiresAtMillis := int64(1700000000000) + + oldStyle := []byte(string(pubPEM) + strconv.FormatInt(expiresAtMillis, 10)) + + newStyle := make([]byte, 0, len(pubPEM)+20) + newStyle = append(newStyle, pubPEM...) + newStyle = strconv.AppendInt(newStyle, expiresAtMillis, 10) + + if string(oldStyle) != string(newStyle) { + t.Fatalf("V1 消息字节不一致\n old=%q\n new=%q", oldStyle, newStyle) + } +} + +// TestSignatureService_GetOrCreateYggdrasilKeyPair_LoadsFromRedis 验证: +// 当进程内缓存为空(新实例)但 Redis 中已有有效密钥时,能正确加载而非重新生成。 +// 使用同一 miniredis 实例,避免新旧实例切换时数据丢失。 +func TestSignatureService_GetOrCreateYggdrasilKeyPair_LoadsFromRedis(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("启动 miniredis 失败: %v", err) + } + t.Cleanup(mr.Close) + + rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + client := &pkgRedis.Client{Client: rdb} + ctx := context.Background() + + // 实例1 生成根密钥(写入 miniredis) + svc1 := NewSignatureService(nil, client, zap.NewNop()) + pub1, priv1, err := svc1.GetOrCreateYggdrasilKeyPair(ctx) + if err != nil { + t.Fatalf("实例1生成失败: %v", err) + } + + // 新建实例2(缓存为空),但 Redis 中已有密钥 + svc2 := NewSignatureService(nil, client, zap.NewNop()) + pub2, priv2, err := svc2.GetOrCreateYggdrasilKeyPair(ctx) + if err != nil { + t.Fatalf("实例2加载失败: %v", err) + } + if pub1 != pub2 { + t.Fatal("从 Redis 加载的公钥 PEM 不一致") + } + // 加载的私钥应与原始私钥在数学上等价(N 相同) + if priv1.N.Cmp(priv2.N) != 0 { + t.Fatal("从 Redis 加载的私钥与原始私钥不匹配") + } +} + +// TestSignatureService_SignConcurrent 验证签名缓存层在并发下的正确性(无数据竞争)。 +func TestSignatureService_SignConcurrent(t *testing.T) { + svc, _, cleanup := newTestSignatureService(t) + defer cleanup() + ctx := context.Background() + + // 先生成根密钥 + seed, err := svc.SignStringWithSHA1withRSA(ctx, "seed") + if err != nil { + t.Fatalf("种子签名失败: %v", err) + } + + const n = 32 + var wg sync.WaitGroup + errs := make(chan error, n) + sigs := make(chan string, n) + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + sig, err := svc.SignStringWithSHA1withRSA(ctx, "seed") + if err != nil { + errs <- err + return + } + sigs <- sig + }() + } + wg.Wait() + close(errs) + close(sigs) + + for err := range errs { + t.Fatalf("并发签名出错: %v", err) + } + for sig := range sigs { + if sig != seed { + t.Fatalf("并发签名结果与预期不一致:got=%q want=%q", sig, seed) + } + } +} + +// TestSignatureService_KeyPairFieldsPopulated 验证 NewKeyPair 返回的 KeyPair 字段完整。 +func TestSignatureService_KeyPairFieldsPopulated(t *testing.T) { + svc, _, cleanup := newTestSignatureService(t) + defer cleanup() + ctx := context.Background() + + kp, err := svc.NewKeyPair(ctx) + if err != nil { + t.Fatalf("NewKeyPair 失败: %v", err) + } + + checks := []struct { + name string + got string + }{ + {"PrivateKey", kp.PrivateKey}, + {"PublicKey", kp.PublicKey}, + {"PublicKeySignature", kp.PublicKeySignature}, + {"PublicKeySignatureV2", kp.PublicKeySignatureV2}, + {"YggdrasilPublicKey", kp.YggdrasilPublicKey}, + } + for _, c := range checks { + if c.got == "" { + t.Errorf("KeyPair.%s 为空", c.name) + } + } + if kp.Expiration.IsZero() || kp.Refresh.IsZero() { + t.Error("KeyPair.Expiration/Refresh 未设置") + } + if !kp.Refresh.Before(kp.Expiration) { + t.Error("Refresh 应早于 Expiration") + } +} + +// 确保引用的包被使用(避免未使用导入在编辑后报错) +var _ = fmt.Sprintf + +// BenchmarkSignStringWithSHA1withRSA 衡量缓存命中路径的签名性能(无 PEM 解析、无 Redis 往返)。 +func BenchmarkSignStringWithSHA1withRSA(b *testing.B) { + mr, err := miniredis.Run() + if err != nil { + b.Fatalf("启动 miniredis 失败: %v", err) + } + defer mr.Close() + + rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()}) + defer func() { _ = rdb.Close() }() + client := &pkgRedis.Client{Client: rdb} + ctx := context.Background() + + svc := NewSignatureService(nil, client, zap.NewNop()) + // 预热:生成根密钥并填充缓存 + if _, err := svc.SignStringWithSHA1withRSA(ctx, "warmup"); err != nil { + b.Fatalf("预热失败: %v", err) + } + + data := strings.Repeat("x", 256) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := svc.SignStringWithSHA1withRSA(ctx, data); err != nil { + b.Fatalf("签名失败: %v", err) + } + } +} diff --git a/internal/service/yggdrasil_certificate_service.go b/internal/service/yggdrasil_certificate_service.go index 72e4a81..9ee2d14 100644 --- a/internal/service/yggdrasil_certificate_service.go +++ b/internal/service/yggdrasil_certificate_service.go @@ -14,11 +14,26 @@ import ( // CertificateService 证书服务接口 type CertificateService interface { // GeneratePlayerCertificate 生成玩家证书 - GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) + GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) // GetPublicKey 获取公钥 GetPublicKey(ctx context.Context) (string, error) } +// PlayerCertificate 玩家证书 +type PlayerCertificate struct { + KeyPair PlayerKeyPair `json:"keyPair"` + PublicKeySignature string `json:"publicKeySignature"` + PublicKeySignatureV2 string `json:"publicKeySignatureV2"` + ExpiresAt int64 `json:"expiresAt"` + RefreshedAfter int64 `json:"refreshedAfter"` +} + +// PlayerKeyPair 玩家证书密钥对 +type PlayerKeyPair struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` +} + // yggdrasilCertificateService 证书服务实现 type yggdrasilCertificateService struct { profileRepo repository.ProfileRepository @@ -40,7 +55,7 @@ func NewCertificateService( } // GeneratePlayerCertificate 生成玩家证书 -func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) { +func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) { if uuid == "" { return nil, apperrors.ErrUUIDRequired } @@ -89,15 +104,15 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont expiresAtMillis := keyPair.Expiration.UnixMilli() // 返回玩家证书 - certificate := map[string]interface{}{ - "keyPair": map[string]interface{}{ - "privateKey": keyPair.PrivateKey, - "publicKey": keyPair.PublicKey, + certificate := &PlayerCertificate{ + KeyPair: PlayerKeyPair{ + PrivateKey: keyPair.PrivateKey, + PublicKey: keyPair.PublicKey, }, - "publicKeySignature": keyPair.PublicKeySignature, - "publicKeySignatureV2": keyPair.PublicKeySignatureV2, - "expiresAt": expiresAtMillis, - "refreshedAfter": keyPair.Refresh.UnixMilli(), + PublicKeySignature: keyPair.PublicKeySignature, + PublicKeySignatureV2: keyPair.PublicKeySignatureV2, + ExpiresAt: expiresAtMillis, + RefreshedAfter: keyPair.Refresh.UnixMilli(), } s.logger.Info("成功生成玩家证书", diff --git a/internal/service/yggdrasil_serialization_service.go b/internal/service/yggdrasil_serialization_service.go index 565fdcc..6014d62 100644 --- a/internal/service/yggdrasil_serialization_service.go +++ b/internal/service/yggdrasil_serialization_service.go @@ -55,8 +55,8 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr // SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式(支持unsigned参数) func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} { - // 创建基本材质数据 - texturesMap := make(map[string]interface{}) + // 预分配材质 map(最多 SKIN + CAPE 两个条目,避免运行时扩容) + texturesMap := make(map[string]interface{}, 2) textures := map[string]interface{}{ "timestamp": time.Now().UnixMilli(), "profileId": profile.UUID, @@ -64,7 +64,7 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context "textures": texturesMap, } - // 处理皮肤 + // 处理皮肤:metadata 仅对 SKIN 有效,值为 {"model":"slim"};classic/默认省略 metadata if profile.SkinID != nil { skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID) if err != nil { @@ -73,14 +73,19 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context zap.Int64("skinID", *profile.SkinID), ) } else if skin != nil { - texturesMap["SKIN"] = map[string]interface{}{ - "url": skin.URL, - "metadata": skin.Size, + // slim 才需要 metadata 字段;classic 直接仅含 url + if skin.IsSlim { + texturesMap["SKIN"] = map[string]interface{}{ + "url": skin.URL, + "metadata": map[string]string{"model": "slim"}, + } + } else { + texturesMap["SKIN"] = map[string]interface{}{"url": skin.URL} } } } - // 处理披风 + // 处理披风:CAPE 不携带 metadata 字段 if profile.CapeID != nil { cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID) if err != nil { @@ -89,15 +94,12 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context zap.Int64("capeID", *profile.CapeID), ) } else if cape != nil { - texturesMap["CAPE"] = map[string]interface{}{ - "url": cape.URL, - "metadata": cape.Size, - } + texturesMap["CAPE"] = map[string]interface{}{"url": cape.URL} } } - // 将textures编码为base64 - bytes, err := json.Marshal(textures) + // 将 textures 编码为 base64 + textureBytes, err := json.Marshal(textures) if err != nil { s.logger.Error("序列化textures失败", zap.Error(err), @@ -106,12 +108,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context return nil } - textureData := base64.StdEncoding.EncodeToString(bytes) + textureData := base64.StdEncoding.EncodeToString(textureBytes) // 只有在 unsigned=false 时才签名 - var signature string + property := Property{ + Name: "textures", + Value: textureData, + } if !unsigned { - signature, err = s.signatureService.SignStringWithSHA1withRSA(ctx, textureData) + signature, err := s.signatureService.SignStringWithSHA1withRSA(ctx, textureData) if err != nil { s.logger.Error("签名textures失败", zap.Error(err), @@ -119,26 +124,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context ) return nil } - } - - // 构建属性 - property := Property{ - Name: "textures", - Value: textureData, - } - - // 只有在 unsigned=false 时才添加签名 - if !unsigned { property.Signature = signature } // 构建结果 - data := map[string]interface{}{ + return map[string]interface{}{ "id": profile.UUID, "name": profile.Name, "properties": []Property{property}, } - return data } // SerializeUser 序列化用户为Yggdrasil格式 diff --git a/internal/service/yggdrasil_service_composite.go b/internal/service/yggdrasil_service_composite.go index 78c1346..5eb6fc8 100644 --- a/internal/service/yggdrasil_service_composite.go +++ b/internal/service/yggdrasil_service_composite.go @@ -125,7 +125,7 @@ func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *mod } // GeneratePlayerCertificate 生成玩家证书 -func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) { +func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) { return s.certificateService.GeneratePlayerCertificate(ctx, uuid) } diff --git a/internal/service/yggdrasil_session_service.go b/internal/service/yggdrasil_session_service.go index dfa27cf..b22eb4c 100644 --- a/internal/service/yggdrasil_session_service.go +++ b/internal/service/yggdrasil_session_service.go @@ -83,7 +83,7 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a return apperrors.ErrInvalidAccessToken } if username == "" { - return apperrors.ErrUsernameMismatch + return apperrors.ErrUsernameRequired } if profileUUID == "" { return apperrors.ErrProfileMismatch @@ -127,12 +127,8 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a return nil } -// GetSession 获取会话数据 +// GetSession 获取会话数据(内部调用前已对 serverID 校验过,跳过重复校验) func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) { - if err := ValidateServerID(serverID); err != nil { - return nil, err - } - // 从Redis获取会话数据 sessionKey := SessionKeyPrefix + serverID data, err := s.redis.GetBytes(ctx, sessionKey) @@ -163,6 +159,7 @@ func (s *yggdrasilSessionService) ValidateSession(ctx context.Context, serverID, return apperrors.ErrSessionMismatch } + // ValidateSession 由 HasJoinedServer 调用,serverID 已经过校验,此处无需重复 ValidateServerID sessionData, err := s.GetSession(ctx, serverID) if err != nil { return apperrors.ErrSessionNotFound diff --git a/internal/service/yggdrasil_validator.go b/internal/service/yggdrasil_validator.go deleted file mode 100644 index 2a01dbe..0000000 --- a/internal/service/yggdrasil_validator.go +++ /dev/null @@ -1,103 +0,0 @@ -package service - -import ( - "errors" - "net" - "regexp" - "strings" -) - -// Validator Yggdrasil验证器 -type Validator struct{} - -// NewValidator 创建验证器实例 -func NewValidator() *Validator { - return &Validator{} -} - -var ( - // emailRegex 邮箱正则表达式 - emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) -) - -// ValidateServerID 验证服务器ID格式 -func (v *Validator) ValidateServerID(serverID string) error { - if serverID == "" { - return errors.New("服务器ID不能为空") - } - if len(serverID) > 100 { - return errors.New("服务器ID长度超过限制(最大100字符)") - } - // 防止注入攻击:检查危险字符 - if strings.ContainsAny(serverID, "<>\"'&") { - return errors.New("服务器ID包含非法字符") - } - return nil -} - -// ValidateIP 验证IP地址格式 -func (v *Validator) ValidateIP(ip string) error { - if ip == "" { - return nil // IP是可选的 - } - if net.ParseIP(ip) == nil { - return errors.New("IP地址格式无效") - } - return nil -} - -// ValidateEmail 验证邮箱格式 -func (v *Validator) ValidateEmail(email string) error { - if email == "" { - return errors.New("邮箱不能为空") - } - if !emailRegex.MatchString(email) { - return errors.New("邮箱格式不正确") - } - return nil -} - -// ValidateUUID 验证UUID格式(支持32位无符号和36位带连字符格式) -func (v *Validator) ValidateUUID(uuid string) error { - if uuid == "" { - return errors.New("UUID不能为空") - } - - // 验证32位无符号UUID格式(纯十六进制字符串) - if len(uuid) == 32 { - // 检查是否为有效的十六进制字符串 - for _, c := range uuid { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return errors.New("UUID格式无效:包含非十六进制字符") - } - } - return nil - } - - // 验证36位标准UUID格式(带连字符) - if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' { - // 检查除连字符外的字符是否为有效的十六进制 - for i, c := range uuid { - if i == 8 || i == 13 || i == 18 || i == 23 { - continue // 跳过连字符位置 - } - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return errors.New("UUID格式无效:包含非十六进制字符") - } - } - return nil - } - - return errors.New("UUID格式无效:长度应为32位或36位") -} - -// ValidateAccessToken 验证访问令牌 -func (v *Validator) ValidateAccessToken(token string) error { - if token == "" { - return errors.New("访问令牌不能为空") - } - if len(token) < 10 { - return errors.New("访问令牌格式无效") - } - return nil -} diff --git a/pkg/auth/yggdrasil_jwt.go b/pkg/auth/yggdrasil_jwt.go index 29348e2..b502851 100644 --- a/pkg/auth/yggdrasil_jwt.go +++ b/pkg/auth/yggdrasil_jwt.go @@ -1,29 +1,13 @@ package auth import ( - "context" - "crypto/rand" "crypto/rsa" - "crypto/x509" - "encoding/pem" "errors" - "fmt" - "sync" "time" "github.com/golang-jwt/jwt/v5" ) -const ( - YggdrasilPrivateKeyRedisKey = "yggdrasil:private_key" -) - -// RedisClient 定义Redis客户端接口(用于测试) -type RedisClient interface { - Get(ctx context.Context, key string) (string, error) - Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error -} - // YggdrasilJWTService Yggdrasil JWT服务(使用RSA512) type YggdrasilJWTService struct { privateKey *rsa.PrivateKey @@ -122,98 +106,3 @@ func (j *YggdrasilJWTService) ParseAccessToken(accessToken string, stalePolicy S func (j *YggdrasilJWTService) GetPublicKey() *rsa.PublicKey { return j.publicKey } - -// YggdrasilJWTManager Yggdrasil JWT管理器,用于获取或创建JWT服务 -type YggdrasilJWTManager struct { - redisClient RedisClient - jwtService *YggdrasilJWTService - privateKey *rsa.PrivateKey - mu sync.RWMutex -} - -// NewYggdrasilJWTManager 创建Yggdrasil JWT管理器 -func NewYggdrasilJWTManager(redisClient RedisClient) *YggdrasilJWTManager { - return &YggdrasilJWTManager{ - redisClient: redisClient, - } -} - -// GetJWTService 获取或创建Yggdrasil JWT服务(线程安全) -func (m *YggdrasilJWTManager) GetJWTService() (*YggdrasilJWTService, error) { - m.mu.RLock() - if m.jwtService != nil { - service := m.jwtService - m.mu.RUnlock() - return service, nil - } - m.mu.RUnlock() - - m.mu.Lock() - defer m.mu.Unlock() - - // 双重检查 - if m.jwtService != nil { - return m.jwtService, nil - } - - // 从Redis获取私钥 - privateKey, err := m.getPrivateKeyFromRedis() - if err != nil { - return nil, fmt.Errorf("获取私钥失败: %w", err) - } - - m.privateKey = privateKey - m.jwtService = NewYggdrasilJWTService(privateKey, "carrotskin") - return m.jwtService, nil -} - -// SetPrivateKey 直接设置私钥(用于测试或直接从signatureService获取) -func (m *YggdrasilJWTManager) SetPrivateKey(privateKey *rsa.PrivateKey) { - m.mu.Lock() - defer m.mu.Unlock() - m.privateKey = privateKey - if privateKey != nil { - m.jwtService = NewYggdrasilJWTService(privateKey, "carrotskin") - } -} - -// getPrivateKeyFromRedis 从Redis获取私钥 -func (m *YggdrasilJWTManager) getPrivateKeyFromRedis() (*rsa.PrivateKey, error) { - if m.privateKey != nil { - return m.privateKey, nil - } - - ctx := context.Background() - privateKeyPEM, err := m.redisClient.Get(ctx, YggdrasilPrivateKeyRedisKey) - if err != nil || privateKeyPEM == "" { - return nil, fmt.Errorf("从Redis获取私钥失败: %w", err) - } - - // 解析PEM格式的私钥 - block, _ := pem.Decode([]byte(privateKeyPEM)) - if block == nil { - return nil, fmt.Errorf("解析PEM私钥失败") - } - - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("解析RSA私钥失败: %w", err) - } - - return privateKey, nil -} - -// GenerateKeyPair 生成RSA密钥对(用于测试) -func GenerateKeyPair() (*rsa.PrivateKey, error) { - return rsa.GenerateKey(rand.Reader, 2048) -} - -// EncodePrivateKeyToPEM 将私钥编码为PEM格式(用于测试) -func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey) (string, error) { - privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) - privateKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: privateKeyBytes, - }) - return string(privateKeyPEM), nil -} diff --git a/pkg/auth/yggdrasil_jwt_test.go b/pkg/auth/yggdrasil_jwt_test.go index 32c73cf..1a9ac61 100644 --- a/pkg/auth/yggdrasil_jwt_test.go +++ b/pkg/auth/yggdrasil_jwt_test.go @@ -1,65 +1,16 @@ package auth import ( - "context" + "crypto/rand" "crypto/rsa" - "errors" "testing" "time" - - "github.com/redis/go-redis/v9" ) -// MockRedisClient 模拟Redis客户端 -type MockRedisClient struct { - data map[string]string - err error -} - -func NewMockRedisClient() *MockRedisClient { - return &MockRedisClient{ - data: make(map[string]string), - } -} - -func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) { - if m.err != nil { - return "", m.err - } - if val, ok := m.data[key]; ok { - return val, nil - } - return "", redis.Nil -} - -func (m *MockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { - if m.err != nil { - return m.err - } - m.data[key] = value.(string) - return nil -} - -func (m *MockRedisClient) SetError(err error) { - m.err = err -} - -func (m *MockRedisClient) ClearError() { - m.err = nil -} - -func (m *MockRedisClient) SetData(key, value string) { - m.data[key] = value -} - -func (m *MockRedisClient) Clear() { - m.data = make(map[string]string) - m.err = nil -} - -// 测试辅助函数:生成测试用的密钥对 +// generateTestKeyPair 生成测试用的 RSA 密钥对 func generateTestKeyPair(t *testing.T) *rsa.PrivateKey { - privateKey, err := GenerateKeyPair() + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("生成密钥对失败: %v", err) } @@ -285,169 +236,6 @@ func TestYggdrasilJWTService_GetPublicKey(t *testing.T) { } } -func TestNewYggdrasilJWTManager(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - if manager == nil { - t.Fatal("管理器创建失败") - } - if manager.redisClient != mockRedis { - t.Error("Redis客户端未正确设置") - } -} - -func TestYggdrasilJWTManager_SetPrivateKey(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - privateKey := generateTestKeyPair(t) - manager.SetPrivateKey(privateKey) - - // 验证JWT服务已创建 - service, err := manager.GetJWTService() - if err != nil { - t.Fatalf("获取JWT服务失败: %v", err) - } - if service == nil { - t.Fatal("JWT服务不应为nil") - } - // 验证服务可以正常工作 - if service.GetPublicKey() == nil { - t.Error("公钥不应为nil") - } -} - -func TestYggdrasilJWTManager_GetJWTService_FromPrivateKey(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - privateKey := generateTestKeyPair(t) - manager.SetPrivateKey(privateKey) - - // 第一次获取 - service1, err := manager.GetJWTService() - if err != nil { - t.Fatalf("获取JWT服务失败: %v", err) - } - - // 第二次获取应该返回同一个实例 - service2, err := manager.GetJWTService() - if err != nil { - t.Fatalf("获取JWT服务失败: %v", err) - } - - if service1 != service2 { - t.Error("应该返回同一个JWT服务实例") - } -} - -func TestYggdrasilJWTManager_GetJWTService_FromRedis(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - privateKey := generateTestKeyPair(t) - privateKeyPEM, err := EncodePrivateKeyToPEM(privateKey) - if err != nil { - t.Fatalf("编码私钥失败: %v", err) - } - - // 设置Redis数据 - mockRedis.SetData(YggdrasilPrivateKeyRedisKey, privateKeyPEM) - - // 获取JWT服务 - service, err := manager.GetJWTService() - if err != nil { - t.Fatalf("获取JWT服务失败: %v", err) - } - if service == nil { - t.Error("JWT服务不应为nil") - } - - // 验证服务可以正常工作 - token, err := service.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid", - time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour)) - if err != nil { - t.Fatalf("生成Token失败: %v", err) - } - if token == "" { - t.Error("Token不应为空") - } -} - -func TestYggdrasilJWTManager_GetJWTService_RedisError(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - // 设置Redis错误 - mockRedis.SetError(errors.New("redis connection error")) - - // 尝试获取JWT服务应该失败 - _, err := manager.GetJWTService() - if err == nil { - t.Error("期望出现错误,但没有错误") - } -} - -func TestYggdrasilJWTManager_GetJWTService_InvalidPEM(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - // 设置无效的PEM数据 - mockRedis.SetData(YggdrasilPrivateKeyRedisKey, "invalid-pem-data") - - // 尝试获取JWT服务应该失败 - _, err := manager.GetJWTService() - if err == nil { - t.Error("期望出现错误,但没有错误") - } -} - -func TestYggdrasilJWTManager_GetJWTService_Concurrent(t *testing.T) { - mockRedis := NewMockRedisClient() - manager := NewYggdrasilJWTManager(mockRedis) - - privateKey := generateTestKeyPair(t) - privateKeyPEM, err := EncodePrivateKeyToPEM(privateKey) - if err != nil { - t.Fatalf("编码私钥失败: %v", err) - } - - mockRedis.SetData(YggdrasilPrivateKeyRedisKey, privateKeyPEM) - - // 并发获取JWT服务 - const numGoroutines = 10 - results := make(chan *YggdrasilJWTService, numGoroutines) - errors := make(chan error, numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func() { - service, err := manager.GetJWTService() - if err != nil { - errors <- err - return - } - results <- service - }() - } - - // 收集结果 - services := make(map[*YggdrasilJWTService]bool) - for i := 0; i < numGoroutines; i++ { - select { - case service := <-results: - services[service] = true - case err := <-errors: - t.Fatalf("获取JWT服务失败: %v", err) - } - } - - // 所有goroutine应该返回同一个服务实例 - if len(services) != 1 { - t.Errorf("期望所有goroutine返回同一个服务实例,但得到 %d 个不同的实例", len(services)) - } -} - func TestYggdrasilTokenClaims_EmptyProfileID(t *testing.T) { privateKey := generateTestKeyPair(t) service := NewYggdrasilJWTService(privateKey, "test-issuer")