前言
在一年前,笔者使用了第一代加密通信(ECDH+AES256)传输数据,但是其缺点非常大(无法对抗MITM,重放攻击等),所以在一年的实践中迭代出了第二、第三代加密通信,另外针对脚本设计了签名的安全链路,现在本篇文档简要介绍。
第三代加密通信已经达到生产环境稳定,现被用于Behemiron项目
三代加密通信的区别
我们知道在项目里有一类很现实的需求:
- 服务端下发资源,客户端临时加载。
- 其中一部分东西(比如资源包、脚本内容)不希望被随便看到或被替换。
如果只是“能通信”,其实很容易;真正麻烦的是:
- 别人能不能偷看(机密性)
- 别人能不能篡改(完整性)
- 我连的到底是不是服务器本人(身份认证,这一块在第一第二代都没有得到很好的解决)
- 旧包被重放会不会造成问题(重放攻击,在第一第二代也没有得到很好的解决)
第一代我们只是把“密码送过去”,能用但不够安全
第二代把数据包升级成 AES-GCM
第三代(本篇)把身份认证、防重放和脚本完整性补齐
补充基础概念
1) 对称加密(AES)
同一把钥匙的锁
- 加密和解密用同一把密钥。
- 优点是快,适合大量数据。
- 但问题是:钥匙怎么安全地交给对方?
2) 非对称加密(ECDH / ECDSA)
每个人一把公钥,一把私钥
-
ECDH:不是用来加密,而是协商共享密钥
- 双方交换“公钥”后,各自用自己的私钥计算出同一份共享密钥
- 共享密钥不会在网络上传输。如此便保证了“钥匙”只被通信双方所拥有
-
ECDSA:不是用来加密,而是证明“我就是我”
- 服务端用私钥签名,客户端用公钥验证
- 私钥是服务端独自享有的,不可外传,而公钥是可以随意分发的,用于解密独属于服务端的签名信息
- 有效签名说明“这段数据确实是服务端发的”
3) 密钥派生(HKDF)
把“原始密钥”加工成“专用密钥”。
- ECDH 得到的是共享密钥,但不直接使用,因为密钥只有一个,太过于单一,安全性欠佳
- 通过 HKDF “搅一遍”,确保每次会话都不一样
- 这样即便原始密钥被重用,会话密钥也不同
4) AES‑GCM(带防篡改的加密)
加密同时“盖章”。
- AES‑GCM 会产生“认证标签”
- 数据哪怕被改 1 bit,解密都会失败
5) 重放攻击 & 滑动窗口
重放攻击就是攻击者把旧包再发一次,例如给予1个物品的包被抓到后重放100次就变成了给予100个
滑动窗口就是记录最近一段序列号,拒绝重复
第一代
第一代的核心逻辑是:
- 服务端发公钥
- 客户端用 ECDH 算共享密钥
- 服务端用共享密钥发资源包密码
- 客户端解密后加载资源
优点:“快能跑”,但缺点也很明显:
- 没有身份认证:中间人可以冒充服务器,也就是常说的MITM攻击。即在服务端和客户端通信链路中间加入一个中间人,服务端发送公钥给客户端时被中间人“截胡”,中间人把自己的公钥发送给服务端。客户端发送公钥给服务端时被中间人“截胡”,中间人把自己的公钥发送给客户端。如此服务端认为中间人是客户端,客户端认为中间人是服务端,在服务端使用自己的私钥+中间人公钥派生的密钥加密消息后发送给中间人,中间人一定能使用自己的私钥解密,篡改内容后中间人再使用自己私钥和客户端发来的公钥计算出的共享密钥加密后发给客户端,因为客户端使用的是中间人公钥+自己私钥派生出的共享密钥解密,所以其也能正常解密消息。客户端向服务端发送消息亦然,如此消息的便可以在“神不知鬼不觉”的情况下被篡改,内容和安全性就无法得到保证
- 没有防重放:旧包重放也能被接受,完全防止不了重放攻击
- 密钥直接使用:没有派生步骤,一份共享密钥被用于所有会话,只要一个会话密钥被破解,其他所有会话均会面临危险
第二代(改造前):只有 ECDH + AES‑GCM
第二代是一个“基础安全升级”:
- 仍然用 ECDH 协商共享密钥
- 对称加密升级到 AES‑GCM
较第一代:
- 数据包一旦被篡改,解密会失败。
不足:
- 仍然不验证服务端身份(MITM 依然奏效)
- 不做密钥派生
- 不做重放防护
第三代(当前)通信的改进
握手流程加强
1) 现在的握手流程
- 客户端发送
ClientHello- 带临时公钥(ECDH)
- 带客户端 Nonce(随机数)
- 服务端返回
ServerHello- 带临时公钥(ECDH)
- 带服务端 Nonce
- 带服务端身份公钥(ECDSA)
- 带签名(证明身份)
- 双方用 ECDH 计算共享密钥
- 用 HKDF 派生会话密钥
- 会话开始,之后所有包都用 AES‑GCM
2) 握手里关键的两件事
2.1 身份认证(ECDSA)
服务端会签名:
clientNonce || serverNonce || serverEphemeralPublicKey
客户端验证签名,一旦签名验证通过,就知道“对方确实是服务器,而不是中间人”,因为中间人无法得到服务端的签名私钥,所以其不可能伪造服务端签名,如此便避免了MITM攻击
2.2 密钥派生(HKDF)
在服务端和客户端得到共享密钥后,将其作为“原料”,针对每个会话派生出独属于此会话的密钥,在会话中真正使用的是派生后的会话密钥:
sessionKey = HKDF-SHA256(sharedSecret, salt=clientNonce||serverNonce, info="session_key")
这样每次连接都会生成新密钥。
防重放攻击:序列号 + 时间戳 + 滑动窗口
第三代的数据包结构变味了:
IV | Seq | Timestamp | Ciphertext | Tag
- IV:随机向量,也就是盐(Salt),用于确保同一个密钥加密同一份报文每次得到的加密结果都是不同的
- Seq:递增序列号
- Timestamp:毫秒时间戳
- CipherText:加密报文
滑动窗口:只接受“窗口内且未出现过”的序列号,每次收到一个新包,窗口内维护的计数器便会自增1,这样即使攻击者捕获旧包重放,因为旧包的Seq还是旧值,所以无法通过滑动窗口验证,攻击者同样无法修改数据包的Seq字段内容,因为数据包使用AES-GCM“盖过章”,篡改内容后无法通过解密验证。与此同时服务端还会验证客户端发送数据包的Timestamp
这样可以同时防:
- 重放旧包
- 大幅延迟
- 网络乱序抖动
TOFU(首次信任)机制
身份认证解决的是“签名是否正确,即我是不是我,你是不是你的问题”,但还有一个问题:
公钥是第一次见到时怎么处理?
我们用 TOFU(Trust On First Use):
- 第一次连接:记录服务端指纹,默认信任
- 后续连接:指纹不一致就提示风险
指纹是:
SHA-256(公钥) 的前 16 字节
保存到客户端 known_servers.json。
另外支持“密钥轮换”,新公钥如果被旧私钥签过,就允许自动更新信任。
脚本安全链路
读者可能会问:
::: align-center
不是已经 AES‑GCM 了,脚本还要签名吗?
:::
答案是:必须要
原因很简单:
- 网络加密保证的是“传输过程安全”
- 而脚本链路保证的是“内容可信”
传输安全只保证“这次传输没被改”,它保护的是数据从服务器到客户端,但并不证明“这份脚本本身是被服务器授权的”。如果服务器端配置出错、被入侵、或者有人拿到了发送权限,那么“传输安全”反而会让这份脚本更顺利地到达客户端,但它依然可能是错误或恶意内容。另外脚本一旦落到本地磁盘或缓存里,它就不再处于“传输安全”的保护范围内,而安全链路里的清单签名是为了让客户端在任何时候都能验证“这个脚本是否还是原版”
::: align-center
传输加密只防路上的人, 脚本链路是防内容本身被替换或来源不明
:::
安全链路的流程
- 服务端计算每个脚本的 SHA‑256
- 生成脚本清单(manifest)
- 用 Ed25519 签名清单
- Ed25519 公钥再由服务器身份私钥(ECDSA)签名,并通过数据包传给客户端
- 客户端使用之前收到的服务端 ECDSA 公钥验证 Ed25519 公钥,之后再使用 Ed25519 公钥验证清单签名 + 校验脚本 hash
信任链:
服务器身份公钥 (ECDSA)
↓ 验证
清单公钥 (Ed25519)
↓ 验证
清单签名
↓ 校验
脚本 hash
这样就算有人劫持网络,也无法伪造脚本。
小结
第三代的目标不是做一个小型 TLS,而是:
- 轻量
- 可控
- 够安全
在游戏环境里,这套链路可以稳定工作,而且对性能影响可控。
目前暂未考虑更严格的安全策略(比如证书验证,因为没有资质)
全部评论 (0)
暂无评论,快来抢沙发吧~