服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/platform/replay"
|
||
"longheng.io/server/pkg/contracts"
|
||
)
|
||
|
||
// 历史背景:LongHeng 的 internal RPC 直到本改动前完全明文 —— Bus / NATS / Redis
|
||
// PubSub 上谁能连进来就能伪造 RPCRequest。生产部署只能靠网络隔离做防线,
|
||
// 一旦 sidecar 漏接、test pod 误配置或同集群另一个服务被攻陷,攻击者就能
|
||
// 直接调用 logic 内部 handler。
|
||
//
|
||
// 本文件提供应用层 HMAC-SHA256 鉴权,作为 mTLS / 内网隔离之外的纵深防御:
|
||
// - 共享 secret 由部署期 Secret/KMS 注入,旋转策略由项目侧实现。
|
||
// - 签名覆盖关键字段(RequestID / SourceNodeID / TargetNodeID / Route /
|
||
// SentAt / payload 哈希),任何字段被改都会失效。
|
||
// - 时间戳偏移检查缩小重放窗口;生产多节点应接入 replay.RedisStore 共享 nonce 表。
|
||
// - Sign* / Verify* 是无状态纯函数,便于在 AuthenticatedTransport 之外的
|
||
// 场景(例如直接走 HTTP 的 Admin 内部接口)单独使用。
|
||
|
||
const (
|
||
// AuthSignatureMetadataKey 是签名字段在 RPCRequest.Metadata /
|
||
// RPCResponse.Metadata 上的固定键。
|
||
AuthSignatureMetadataKey = "x-longheng-signature"
|
||
// AuthAlgorithmMetadataKey 用于在 metadata 里声明算法版本,便于未来兼容
|
||
// 多版本(HMAC-SHA256 / HMAC-SHA512 / Ed25519)。
|
||
AuthAlgorithmMetadataKey = "x-longheng-signature-alg"
|
||
// AuthAlgorithmHMACSHA256 是当前唯一支持的算法标识。
|
||
AuthAlgorithmHMACSHA256 = "HMAC-SHA256"
|
||
// DefaultAuthMaxSkew 是默认的双向时间偏移容差。生产建议保持 5min 量级,
|
||
// 太小容易因为 NTP 抖动误拒,太大会扩大重放窗口。
|
||
DefaultAuthMaxSkew = 5 * time.Minute
|
||
)
|
||
|
||
var (
|
||
// ErrAuthSecretRequired 在构造 Authenticator 时 secret 为空返回。
|
||
ErrAuthSecretRequired = errors.New("rpcauth: shared secret is required")
|
||
// ErrAuthSignatureMissing 表示请求 / 响应缺少签名 metadata。
|
||
ErrAuthSignatureMissing = errors.New("rpcauth: signature missing")
|
||
// ErrAuthSignatureInvalid 表示签名不匹配(密钥不对或字段被改)。
|
||
ErrAuthSignatureInvalid = errors.New("rpcauth: signature invalid")
|
||
// ErrAuthAlgUnsup 表示对端声明了不支持的签名算法。
|
||
ErrAuthAlgUnsup = errors.New("rpcauth: algorithm not supported")
|
||
// ErrAuthTimestampSkew 表示请求 / 响应的 SentAt 偏移超出允许窗口。
|
||
ErrAuthTimestampSkew = errors.New("rpcauth: timestamp outside max skew")
|
||
// ErrAuthReqNoMeta 在签名时 RPCRequest 为 nil 等场景下返回。
|
||
ErrAuthReqNoMeta = errors.New("rpcauth: request is nil")
|
||
// ErrAuthReplayDetected 表示同一 SourceNodeID + RequestID 已在 MaxSkew 窗口内验过;可用 errors.Is 判定。
|
||
ErrAuthReplayDetected = errors.New("rpcauth: replay detected")
|
||
// ErrAuthReplayCheckFailed 表示 nonce store 不可用;可用 errors.Is 保留底层 replay/redis 错误。
|
||
ErrAuthReplayCheckFailed = errors.New("rpcauth: replay check failed")
|
||
)
|
||
|
||
// Authenticator 持有共享密钥和默认偏移窗口,是 RPC 鉴权的核心入口。
|
||
// 所有方法在常量时间比较签名(使用 hmac.Equal / subtle.ConstantTimeCompare),
|
||
// 不会通过失败用时长泄露签名长度。
|
||
type Authenticator struct {
|
||
secret []byte
|
||
maxSkew time.Duration
|
||
now func() time.Time
|
||
replay replay.Store
|
||
}
|
||
|
||
// AuthenticatorOptions 用于构造 Authenticator。Secret 必填;MaxSkew=0 使用
|
||
// DefaultAuthMaxSkew;ReplayStore 为空时保持旧的纯签名校验语义。
|
||
type AuthenticatorOptions struct {
|
||
Secret []byte
|
||
MaxSkew time.Duration
|
||
Now func() time.Time
|
||
ReplayStore replay.Store
|
||
}
|
||
|
||
// NewAuthenticator 校验配置并返回 Authenticator。secret 为空会返回
|
||
// ErrAuthSecretRequired,避免装配代码静默退化为"无鉴权"。
|
||
func NewAuthenticator(opts AuthenticatorOptions) (*Authenticator, error) {
|
||
if len(opts.Secret) == 0 {
|
||
return nil, ErrAuthSecretRequired
|
||
}
|
||
skew := opts.MaxSkew
|
||
if skew <= 0 {
|
||
skew = DefaultAuthMaxSkew
|
||
}
|
||
now := opts.Now
|
||
if now == nil {
|
||
now = time.Now
|
||
}
|
||
// 复制 secret,避免调用方后续修改影响 Authenticator。
|
||
secret := make([]byte, len(opts.Secret))
|
||
copy(secret, opts.Secret)
|
||
return &Authenticator{secret: secret, maxSkew: skew, now: now, replay: opts.ReplayStore}, nil
|
||
}
|
||
|
||
// SignRequest 计算 RPCRequest 的 HMAC 签名并写到 Metadata。req 不能为 nil。
|
||
// SentAt 为零值时会自动填当前时间;Metadata 为 nil 时会被初始化。
|
||
func (a *Authenticator) SignRequest(req *contracts.RPCRequest) error {
|
||
if a == nil {
|
||
return ErrAuthSecretRequired
|
||
}
|
||
if req == nil {
|
||
return ErrAuthReqNoMeta
|
||
}
|
||
if req.SentAt.IsZero() {
|
||
req.SentAt = a.now().UTC()
|
||
}
|
||
if req.Metadata == nil {
|
||
req.Metadata = map[string]string{}
|
||
}
|
||
sig := a.requestSignature(*req)
|
||
req.Metadata[AuthSignatureMetadataKey] = sig
|
||
req.Metadata[AuthAlgorithmMetadataKey] = AuthAlgorithmHMACSHA256
|
||
return nil
|
||
}
|
||
|
||
// VerifyRequest 检查 RPCRequest 是否有合法签名 + 时间戳在允许偏移内。
|
||
// 缺签名返回 ErrAuthSignatureMissing;签名不匹配返回 ErrAuthSignatureInvalid;
|
||
// 时间戳偏移过大返回 ErrAuthTimestampSkew;算法不支持返回 ErrAuthAlgUnsup;
|
||
// nonce 重复返回 ErrAuthReplayDetected。
|
||
func (a *Authenticator) VerifyRequest(req contracts.RPCRequest) error {
|
||
return a.VerifyRequestContext(context.Background(), req)
|
||
}
|
||
|
||
// VerifyRequestContext 与 VerifyRequest 语义一致,但会把 ctx 传给 ReplayStore。
|
||
// 生产 Redis nonce 表应继承调用链超时,否则鉴权路径可能被 Redis 卡死拖垮 handler。
|
||
func (a *Authenticator) VerifyRequestContext(ctx context.Context, req contracts.RPCRequest) error {
|
||
if a == nil {
|
||
return ErrAuthSecretRequired
|
||
}
|
||
sig := strings.TrimSpace(req.Metadata[AuthSignatureMetadataKey])
|
||
if sig == "" {
|
||
return ErrAuthSignatureMissing
|
||
}
|
||
if alg := strings.TrimSpace(req.Metadata[AuthAlgorithmMetadataKey]); alg != "" && alg != AuthAlgorithmHMACSHA256 {
|
||
return fmt.Errorf("%w: %s", ErrAuthAlgUnsup, alg)
|
||
}
|
||
if strings.TrimSpace(req.RequestID) == "" || strings.TrimSpace(req.SourceNodeID) == "" {
|
||
return fmt.Errorf("%w: request_id and source_node_id are required", ErrAuthSignatureMissing)
|
||
}
|
||
expected := a.requestSignature(req)
|
||
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
|
||
return ErrAuthSignatureInvalid
|
||
}
|
||
if err := a.checkSkew(req.SentAt); err != nil {
|
||
return err
|
||
}
|
||
if err := a.checkReplay(ctx, req); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SignResponse 与 SignRequest 对称,用于网关 / logic 之间的响应消息。
|
||
func (a *Authenticator) SignResponse(resp *contracts.RPCResponse) error {
|
||
if a == nil {
|
||
return ErrAuthSecretRequired
|
||
}
|
||
if resp == nil {
|
||
return ErrAuthReqNoMeta
|
||
}
|
||
if resp.SentAt.IsZero() {
|
||
resp.SentAt = a.now().UTC()
|
||
}
|
||
if resp.Metadata == nil {
|
||
resp.Metadata = map[string]string{}
|
||
}
|
||
sig := a.responseSignature(*resp)
|
||
resp.Metadata[AuthSignatureMetadataKey] = sig
|
||
resp.Metadata[AuthAlgorithmMetadataKey] = AuthAlgorithmHMACSHA256
|
||
return nil
|
||
}
|
||
|
||
// VerifyResponse 校验 RPCResponse 的签名。语义与 VerifyRequest 一致。
|
||
func (a *Authenticator) VerifyResponse(resp contracts.RPCResponse) error {
|
||
return a.VerifyResponseContext(context.Background(), resp)
|
||
}
|
||
|
||
// VerifyResponseContext 与 VerifyResponse 语义一致,但会把 ctx 传给 ReplayStore。
|
||
func (a *Authenticator) VerifyResponseContext(ctx context.Context, resp contracts.RPCResponse) error {
|
||
if a == nil {
|
||
return ErrAuthSecretRequired
|
||
}
|
||
sig := strings.TrimSpace(resp.Metadata[AuthSignatureMetadataKey])
|
||
if sig == "" {
|
||
return ErrAuthSignatureMissing
|
||
}
|
||
if alg := strings.TrimSpace(resp.Metadata[AuthAlgorithmMetadataKey]); alg != "" && alg != AuthAlgorithmHMACSHA256 {
|
||
return fmt.Errorf("%w: %s", ErrAuthAlgUnsup, alg)
|
||
}
|
||
if strings.TrimSpace(resp.RequestID) == "" || strings.TrimSpace(resp.SourceNodeID) == "" || strings.TrimSpace(resp.TargetNodeID) == "" {
|
||
return fmt.Errorf("%w: request_id, source_node_id and target_node_id are required", ErrAuthSignatureMissing)
|
||
}
|
||
expected := a.responseSignature(resp)
|
||
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
|
||
return ErrAuthSignatureInvalid
|
||
}
|
||
if err := a.checkSkew(resp.SentAt); err != nil {
|
||
return err
|
||
}
|
||
if err := a.checkResponseReplay(ctx, resp); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MaxSkew 暴露当前的偏移窗口,便于装配代码做断言或日志。
|
||
func (a *Authenticator) MaxSkew() time.Duration {
|
||
if a == nil {
|
||
return 0
|
||
}
|
||
return a.maxSkew
|
||
}
|
||
|
||
func (a *Authenticator) checkSkew(at time.Time) error {
|
||
if a.maxSkew <= 0 {
|
||
return nil
|
||
}
|
||
if at.IsZero() {
|
||
return ErrAuthTimestampSkew
|
||
}
|
||
now := a.now().UTC()
|
||
diff := now.Sub(at.UTC())
|
||
if diff > a.maxSkew || diff < -a.maxSkew {
|
||
return ErrAuthTimestampSkew
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Authenticator) checkReplay(ctx context.Context, req contracts.RPCRequest) error {
|
||
if a.replay == nil {
|
||
return nil
|
||
}
|
||
return a.checkReplayKey(ctx, replayKey(req))
|
||
}
|
||
|
||
func (a *Authenticator) checkResponseReplay(ctx context.Context, resp contracts.RPCResponse) error {
|
||
if a.replay == nil {
|
||
return nil
|
||
}
|
||
return a.checkReplayKey(ctx, responseReplayKey(resp))
|
||
}
|
||
|
||
func (a *Authenticator) checkReplayKey(ctx context.Context, key string) error {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
first, err := a.replay.Seen(ctx, key, authReplayTTL(a.maxSkew))
|
||
if err != nil {
|
||
return fmt.Errorf("%w: %w", ErrAuthReplayCheckFailed, err)
|
||
}
|
||
if !first {
|
||
return ErrAuthReplayDetected
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func replayKey(req contracts.RPCRequest) string {
|
||
source := strings.TrimSpace(req.SourceNodeID)
|
||
requestID := strings.TrimSpace(req.RequestID)
|
||
if source == "" || requestID == "" {
|
||
return ""
|
||
}
|
||
return "v1:" + encodeReplayKeyPart(source) + ":" + encodeReplayKeyPart(requestID)
|
||
}
|
||
|
||
func responseReplayKey(resp contracts.RPCResponse) string {
|
||
source := strings.TrimSpace(resp.SourceNodeID)
|
||
target := strings.TrimSpace(resp.TargetNodeID)
|
||
requestID := strings.TrimSpace(resp.RequestID)
|
||
if source == "" || target == "" || requestID == "" {
|
||
return ""
|
||
}
|
||
return "v1:resp:" + encodeReplayKeyPart(source) + ":" + encodeReplayKeyPart(target) + ":" + encodeReplayKeyPart(requestID)
|
||
}
|
||
|
||
func encodeReplayKeyPart(value string) string {
|
||
if value == "" {
|
||
return "_"
|
||
}
|
||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||
}
|
||
|
||
func authReplayTTL(maxSkew time.Duration) time.Duration {
|
||
if maxSkew <= 0 {
|
||
return 2*DefaultAuthMaxSkew + time.Minute
|
||
}
|
||
return 2*maxSkew + time.Minute
|
||
}
|
||
|
||
// requestSignature 拼出 canonical string 然后 HMAC。canonical 故意排除
|
||
// Metadata(避免循环依赖签名字段本身),只覆盖 routing / identity / 时间
|
||
// / payload 哈希这几个攻击面最大的字段。
|
||
func (a *Authenticator) requestSignature(req contracts.RPCRequest) string {
|
||
canonical := strings.Join([]string{
|
||
"rpc.request",
|
||
req.RequestID,
|
||
req.SourceService,
|
||
req.SourceNodeID,
|
||
req.TargetNodeID,
|
||
req.Route,
|
||
req.SentAt.UTC().Format(time.RFC3339Nano),
|
||
hashPayload(req.Payload),
|
||
}, "\n")
|
||
return a.compute(canonical)
|
||
}
|
||
|
||
// responseSignature 与 request 对称,但用 Error 字段替代 Route,强制响应里
|
||
// 即使内容相同的失败也对应不同的签名(攻击者无法复制一个 OK 响应去顶替一个失败的)。
|
||
func (a *Authenticator) responseSignature(resp contracts.RPCResponse) string {
|
||
canonical := strings.Join([]string{
|
||
"rpc.response",
|
||
resp.RequestID,
|
||
resp.SourceNodeID,
|
||
resp.TargetNodeID,
|
||
resp.Error,
|
||
resp.SentAt.UTC().Format(time.RFC3339Nano),
|
||
hashPayload(resp.Payload),
|
||
}, "\n")
|
||
return a.compute(canonical)
|
||
}
|
||
|
||
func (a *Authenticator) compute(canonical string) string {
|
||
mac := hmac.New(sha256.New, a.secret)
|
||
_, _ = mac.Write([]byte(canonical))
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
// hashPayload 把 payload 摘要成 sha256,避免 canonical string 巨大、CPU 浪费在
|
||
// HMAC 内部分块上。空 payload 显式返回固定标记,便于跨语言重现。
|
||
func hashPayload(payload []byte) string {
|
||
if len(payload) == 0 {
|
||
return "empty"
|
||
}
|
||
sum := sha256.Sum256(payload)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|