Files
backend/internal/service/signature_service_test.go

404 lines
12 KiB
Go
Raw Normal View History

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