refactor(yggdrasil): 整理与性能优化,修复若干 Bug
Some checks failed
Build / build (push) Failing after 21m45s
Build / build-docker (push) Has been skipped

Bug 修复
- textures metadata:SKIN 仅在 IsSlim 时输出 {"model":"slim"};CAPE 不带 metadata。
  原实现误用文件字节数 skin.Size 作为 metadata,违反 Yggdrasil 协议。
- KeyPair 持久化不全:Profile 新增 rsa_public_key/public_key_signature/
  public_key_signature_v2/key_expires_at/key_refresh_at 列;GetKeyPair/UpdateKeyPair
  读写全部字段。AutoMigrate 自动加列。原实现每次请求都重新生成 RSA-4096。
- GetPlayerCertificates:无效 token 返回 401(先前误用 403 且未判 err)。
- HasJoinedServer:失败返回 204 无 body,符合 Yggdrasil 协议(原用 APIResponse
  + body 违反 204 规范)。

代码质量
- Authenticate 删除冗余 body 读取与回放。
- 证书服务引入具名结构 PlayerCertificate/PlayerKeyPair,替代 map[string]interface{}。
- CreateSession 用户名缺失改用 ErrUsernameRequired(新增)。

性能优化(签名一致性的回归测试已覆盖)
- SignatureService 缓存已解析的根私钥(sync.RWMutex 双重检查),
  快路径仅一次 RLock + RSA 签名,免去每次签名 PEM 解析与 Redis 往返。
- GetOrCreateYggdrasilKeyPair 用 MGet 单次往返取三字段,缓存命中后零 Redis。
- NewKeyPair 消息构造用 strconv.AppendInt 避免 string+拼接分配;V2 复用 DER 字节。
- 序列化服务:texturesMap 预分配 cap(2);slim 分支直接构造完整 map。
- 会话服务 GetSession 移除重复的 ValidateServerID 校验。

死代码清理
- 删除 yggdrasil_validator.go(未被调用的 Validator)。
- 删除 signature_service.FormatPublicKey/SignStringWithProfileRSA。
- 删除 pkg/auth 中未被使用的 YggdrasilJWTManager 与 GenerateKeyPair/
  EncodePrivateKeyToPEM/RedisClient/YggdrasilPrivateKeyRedisKey。
- 删除 yggdrasil_handler.go 中 25 个未使用常量、passwordRegex、
  APIResponse/standardResponse。
- 删除 errors.go 中未被使用的 ErrYggForbiddenOperation/ErrYggIllegalArgument/
  ErrInvalidSignature/ErrInvalidTextureType/ErrCertificateGenerate/ErrInvalidPassword。

测试
- 新增 signature_service_test.go(基于 miniredis):8 个测试 + 基准。
  核心:SignString_MatchesReference 与重构前参考实现逐字节比对签名输出一致。
  -race 检测通过;基准约 7.1ms/op(缓存命中路径)。

附带(与本次任务前已存在的未提交改动)
- handler/captcha_handler:将响应字段 msg 改为 message,与前端约定对齐。
This commit is contained in:
2026-07-09 17:18:26 +08:00
parent cea22951b9
commit 1dc9c36a3a
15 changed files with 636 additions and 716 deletions

View File

@@ -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("请求参数错误")

View File

@@ -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": "验证失败,请重试",
})
}
}

View File

@@ -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,
"",
))

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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("缓存命中后仍写入 Rediskeys 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)
}
}
}

View File

@@ -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("成功生成玩家证书",

View File

@@ -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格式

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}