Files
backend/internal/service/signature_service_test.go
lan 1dc9c36a3a
Some checks failed
Build / build (push) Failing after 21m45s
Build / build-docker (push) Has been skipped
refactor(yggdrasil): 整理与性能优化,修复若干 Bug
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,与前端约定对齐。
2026-07-09 17:18:26 +08:00

404 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}