1. 项目背景与核心需求拆解
1.1 为什么游戏项目里会用到 RSA
很多做 Unity 的朋友第一反应是:游戏客户端里搞加密,不是脱裤子放屁吗?客户端代码都能被反编译,密钥还能藏得住?这个质疑本身没错,但实际项目里,RSA 在 Unity 中出现的场景往往不是为了“保护客户端”,而是为了解决几个非常具体的工程问题。
我接触过的需求大致分三类。第一类是客户端与自建服务端的通信签名,比如登录请求、支付回调、排行榜提交,需要防止中间人篡改数据包。第二类是本地存档或配置文件的防篡改校验,用私钥对关键数据签名,客户端用公钥验签,玩家改存档就会校验失败。第三类是资源包的授权分发,服务端用私钥签名资源清单,客户端用公钥验证资源来源是否合法。
这三类场景有一个共同点:公钥可以公开,私钥必须保密。而标题里提到的“公钥加密私钥解密”和“私钥加密公钥解密”其实是两种完全不同的用途,很多人会混淆,后面我会详细拆开讲。
1.2 两种方向到底有什么区别
先把概念理清楚,这是整个项目的地基。
公钥加密、私钥解密:这是标准的加密通信模型。A 想把机密数据发给 B,就用 B 公开的公钥加密,只有持有私钥的 B 能解开。在 Unity 场景里,通常是客户端用服务端下发的公钥加密敏感数据(比如账号密码、支付信息),服务端用私钥解密。
私钥加密、公钥解密:严格来说这不叫“加密”,而叫数字签名。服务端用私钥对数据摘要进行签名,客户端用公钥验证签名。因为公钥是公开的,任何人都能“解开”签名,所以它不提供保密性,只提供身份认证和完整性校验。
注意:很多教程把“私钥加密公钥解密”直接叫加密,这在工程上容易误导新人。你在代码注释里最好写清楚是 Sign/Verify,而不是 Encrypt/Decrypt,否则半年后接手的人会一脸懵。
1.3 目标读者与前置知识
这篇内容适合已经在 Unity 里写过 C# 脚本、了解基本的System.Security.Cryptography命名空间、但对非对称加密实操不太熟的开发者。如果你连byte[]和string的转换都还没搞明白,建议先去补一下编码基础,不然下面讲 Base64 和 PEM 格式转换的时候会很痛苦。
另外说明一下,Unity 不同版本对 .NET 的支持差异很大。Unity 2021 LTS 之后默认是 .NET Standard 2.1,RSACryptoServiceProvider和RSA.Create()都能用,但某些平台(比如 WebGL)对加密库的支持有限制。这些坑我在第四节会集中讲。
2. 核心原理与方案选型解析
2.1 RSA 的数学基础用大白话讲
RSA 的安全性建立在一个事实之上:把两个大质数乘起来很容易,但把乘积分解回两个质数极其困难。你选两个质数 p 和 q,算出 n = p × q,n 就是模数。公钥是 (n, e),私钥是 (n, d),其中 e 和 d 满足一定的数学关系。
加密过程就是:密文 = 明文的 e 次方 mod n。解密就是:明文 = 密文的 d 次方 mod n。听起来简单,但实际工程里你不需要自己算这些,.NET 的 RSA 类都封装好了。
关键在于密钥长度。512 位已经被认为不安全,1024 位在逐步淘汰,2048 位是目前的主流底线,4096 位更安全但性能开销明显增大。在 Unity 移动端上,4096 位的密钥生成可能耗时几百毫秒甚至更久,如果你在游戏启动时同步生成密钥,玩家会明显感到卡顿。
2.2 为什么选 RSA 而不是 AES
这是新手最常问的问题。答案很简单:它们解决的不是同一个问题。
AES 是对称加密,加密和解密用同一把密钥,速度快,适合加密大量数据。但问题是:你怎么把 AES 密钥安全地传给对方?这就回到了“先有鸡还是先有蛋”的困境。
RSA 是非对称加密,公钥可以随便公开,解决了密钥分发问题。但 RSA 速度慢,而且能加密的数据长度受密钥长度限制。以 2048 位密钥为例,PKCS#1 v1.5 填充模式下最多加密 245 字节,OAEP 填充模式下更少。
所以实际工程中的标准做法是混合加密:用 RSA 加密一个临时的 AES 密钥,然后用 AES 加密真正的业务数据。这样既解决了密钥分发,又保证了性能。Unity 里做网络通信加密,几乎都是这个套路。
2.3 Unity 中可用的 RSA 实现对比
| 实现类 | 适用平台 | 密钥导入方式 | 备注 |
|---|---|---|---|
RSACryptoServiceProvider | 大多数桌面/移动平台 | XML、CSP Blob | 老 API,WebGL 不支持 |
RSA.Create() | .NET Standard 2.0+ | 需手动处理 PEM/DER | 跨平台推荐 |
System.Security.Cryptography.RSA | 同上 | 同上 | 抽象基类,实际返回平台实现 |
| 第三方库(如 BouncyCastle) | 全平台 | PEM 直接读取 | 体积大,但兼容性最好 |
我的建议是:如果目标平台包含 WebGL,直接用 BouncyCastle 或者自己写 PEM 解析。因为 WebGL 环境下RSACryptoServiceProvider直接抛异常,而RSA.Create()在部分 Unity 版本里也有坑。如果只做 Android/iOS/PC,RSA.Create()配合手动 PEM 解析基本够用。
2.4 填充模式的选择
RSA 加密前需要对明文进行填充,否则同样的明文每次加密结果都一样,容易被分析。常见填充模式:
- PKCS#1 v1.5:老标准,兼容性好,但存在一些理论上的攻击面
- OAEP:更安全,推荐新项目使用,但需要双方都支持
- PSS:主要用于签名,不是加密
在 .NET 里,RSA.Encrypt(data, RSAEncryptionPadding.Pkcs1)和RSAEncryptionPadding.OaepSHA256是两种常用选择。跨语言通信时一定要确认对方用的哪种填充,否则解密必然失败。我就遇到过服务端用 Java 的 OAEP,客户端用 C# 的 PKCS1,调了一下午才发现是填充不匹配。
3. 完整实操流程与代码实现
3.1 密钥对的生成与格式转换
第一步是生成密钥对。在 .NET 里可以这样写:
using System.Security.Cryptography; public static (string publicKey, string privateKey) GenerateRsaKeyPair(int keySize = 2048) { using (var rsa = RSA.Create(keySize)) { string publicKey = rsa.ToXmlString(false); string privateKey = rsa.ToXmlString(true); return (publicKey, privateKey); } }但ToXmlString在 .NET Core / .NET Standard 里已经被标记为过时,而且 WebGL 不支持。更通用的做法是导出为 PEM 格式:
public static (string publicPem, string privatePem) GenerateRsaPem(int keySize = 2048) { using (var rsa = RSA.Create(keySize)) { byte[] privateKeyBytes = rsa.ExportPkcs8PrivateKey(); byte[] publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); string privatePem = "-----BEGIN PRIVATE KEY-----\n" + Convert.ToBase64String(privateKeyBytes, Base64FormattingOptions.InsertLineBreaks) + "\n-----END PRIVATE KEY-----"; string publicPem = "-----BEGIN PUBLIC KEY-----\n" + Convert.ToBase64String(publicKeyBytes, Base64FormattingOptions.InsertLineBreaks) + "\n-----END PUBLIC KEY-----"; return (publicPem, privatePem); } }提示:
ExportPkcs8PrivateKey和ExportSubjectPublicKeyInfo在 .NET Standard 2.1 才可用。如果你的 Unity 版本较老,需要用ExportParameters手动拼接 DER 结构,或者引入 BouncyCastle。
生成之后,私钥绝对不能放在客户端。公钥可以硬编码在客户端或者从服务端下发。私钥保存在服务端,最好放在环境变量或密钥管理服务里,不要提交到代码仓库。
3.2 公钥加密私钥解密的实现
客户端用公钥加密:
public static string EncryptWithPublicKey(string plainText, string publicPem) { byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); using (var rsa = RSA.Create()) { rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String( publicPem.Replace("-----BEGIN PUBLIC KEY-----", "") .Replace("-----END PUBLIC KEY-----", "") .Replace("\n", "").Replace("\r", "")), out _); byte[] cipherBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256); return Convert.ToBase64String(cipherBytes); } }服务端用私钥解密:
public static string DecryptWithPrivateKey(string cipherBase64, string privatePem) { byte[] cipherBytes = Convert.FromBase64String(cipherBase64); using (var rsa = RSA.Create()) { rsa.ImportPkcs8PrivateKey(Convert.FromBase64String( privatePem.Replace("-----BEGIN PRIVATE KEY-----", "") .Replace("-----END PRIVATE KEY-----", "") .Replace("\n", "").Replace("\r", "")), out _); byte[] plainBytes = rsa.Decrypt(cipherBytes, RSAEncryptionPadding.OaepSHA256); return Encoding.UTF8.GetString(plainBytes); } }这里有几个关键点。第一,PEM 里的换行符必须去掉,否则 Base64 解码会失败。第二,ImportSubjectPublicKeyInfo的第二个参数是out int bytesRead,不要漏掉。第三,加密结果用 Base64 编码后再传输,因为密文是二进制,直接转字符串会乱码。
3.3 私钥签名公钥验签的实现
签名和验签是另一个方向。服务端用私钥签名:
public static string SignWithPrivateKey(string data, string privatePem) { byte[] dataBytes = Encoding.UTF8.GetBytes(data); using (var rsa = RSA.Create()) { rsa.ImportPkcs8PrivateKey(Convert.FromBase64String( privatePem.Replace("-----BEGIN PRIVATE KEY-----", "") .Replace("-----END PRIVATE KEY-----", "") .Replace("\n", "").Replace("\r", "")), out _); byte[] signature = rsa.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(signature); } }客户端用公钥验签:
public static bool VerifyWithPublicKey(string data, string signatureBase64, string publicPem) { byte[] dataBytes = Encoding.UTF8.GetBytes(data); byte[] signatureBytes = Convert.FromBase64String(signatureBase64); using (var rsa = RSA.Create()) { rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String( publicPem.Replace("-----BEGIN PUBLIC KEY-----", "") .Replace("-----END PUBLIC KEY-----", "") .Replace("\n", "").Replace("\r", "")), out _); return rsa.VerifyData(dataBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } }注意签名用的填充是RSASignaturePadding.Pkcs1或Pss,和加密的填充是两套体系,不要搞混。哈希算法推荐 SHA256,SHA1 已经不安全了。
3.4 密钥长度与性能的实测数据
我在一台中端 Android 手机上做了简单测试,数据仅供参考:
| 密钥长度 | 生成耗时 | 加密耗时(245字节内) | 解密耗时 | 签名耗时 | 验签耗时 |
|---|---|---|---|---|---|
| 1024 位 | 约 80ms | 约 1ms | 约 2ms | 约 1ms | 约 1ms |
| 2048 位 | 约 400ms | 约 2ms | 约 5ms | 约 2ms | 约 2ms |
| 4096 位 | 约 3000ms | 约 8ms | 约 20ms | 约 8ms | 约 8ms |
可以看到,密钥生成才是真正的性能瓶颈,加密解密本身很快。所以千万不要在游戏运行时动态生成密钥对,应该在服务端预生成好,客户端只做导入和加解密操作。
实操心得:如果你的项目需要在客户端生成密钥对(比如设备唯一标识场景),建议在加载界面异步生成,或者用 1024 位密钥配合其他手段增强安全性。2048 位在低端机上生成超过 500ms 是常事。
4. 常见问题与排查技巧实录
4.1 跨平台兼容性踩坑记录
问题一:WebGL 平台报PlatformNotSupportedException
这是最常见的坑。Unity WebGL 使用的是 IL2CPP 编译到 WebAssembly,很多 .NET 的加密 API 在 WebGL 下不可用。RSACryptoServiceProvider直接不支持,RSA.Create()在部分 Unity 版本里也会失败。
解决方案有两个:一是引入 BouncyCastle 的 Unity 版本,它用纯 C# 实现了 RSA,不依赖平台 API;二是把加解密操作放到服务端,客户端只负责传输数据。我个人更推荐第二种,因为 WebGL 本身就不适合做敏感加密操作。
问题二:Android 上ImportPkcs8PrivateKey抛异常
某些 Android 设备的 Mono 运行时对 PKCS#8 格式支持不完整。如果你遇到这个问题,可以尝试用ImportRSAPrivateKey导入 PKCS#1 格式的私钥,或者用RSAParameters手动赋值。
// 手动导入 RSAParameters 的示例 var rsaParams = new RSAParameters { Modulus = Convert.FromBase64String(modulusBase64), Exponent = Convert.FromBase64String(exponentBase64), D = Convert.FromBase64String(dBase64), P = Convert.FromBase64String(pBase64), Q = Convert.FromBase64String(qBase64), DP = Convert.FromBase64String(dpBase64), DQ = Convert.FromBase64String(dqBase64), InverseQ = Convert.FromBase64String(inverseQBase64) }; rsa.ImportParameters(rsaParams);这种方式最原始但兼容性最好,缺点是密钥参数需要单独管理。
问题三:iOS 上加密结果和服务端不一致
iOS 的 IL2CPP 在某些 Unity 版本里对RSAEncryptionPadding.OaepSHA256的实现有差异。我遇到过客户端加密后服务端解不开的情况,最后发现是 iOS 端默认用了 SHA1 做 OAEP 的哈希。解决办法是显式指定OaepSHA256,并且确认服务端也用 SHA256。
4.2 数据长度超限的处理
前面提到,2048 位密钥在 OAEP SHA256 填充下最多加密 190 字节左右。如果你要加密的数据超过这个长度,直接调用Encrypt会抛CryptographicException。
处理方式有三种:
- 分段加密:把数据切成小块分别加密,然后拼接。但这样效率低,而且拼接后的密文长度会膨胀。
- 混合加密:用 RSA 加密一个 AES 密钥,用 AES 加密实际数据。这是标准做法。
- 只加密摘要:如果目的是完整性校验而非保密,只对数据的哈希值签名即可。
我一般推荐第二种。具体做法是:客户端生成一个随机的 AES 密钥和 IV,用 RSA 公钥加密 AES 密钥,用 AES 加密业务数据,把加密后的 AES 密钥和密文一起发给服务端。服务端用 RSA 私钥解密得到 AES 密钥,再解密业务数据。
4.3 常见异常速查表
| 异常信息 | 可能原因 | 排查方向 |
|---|---|---|
CryptographicException: Bad Data | 密钥格式错误、填充不匹配 | 检查 PEM 换行符、确认双方填充模式一致 |
CryptographicException: Length of the data to decrypt is not valid | 密文被截断或 Base64 解码错误 | 检查传输过程中是否被转义、Base64 字符串是否完整 |
PlatformNotSupportedException | 当前平台不支持该 API | WebGL 换 BouncyCastle 或服务端处理 |
NullReferenceException | 密钥导入失败返回 null | 检查ImportSubjectPublicKeyInfo的返回值 |
| 解密结果乱码 | 编码不一致 | 确认双方都用 UTF-8 |
4.4 安全实践中的几个关键注意点
第一,不要在客户端硬编码私钥。我见过有项目把私钥直接写在 C# 脚本里,反编译后一目了然。私钥只能存在于服务端。
第二,公钥也要防篡改。如果攻击者能替换客户端的公钥,就能伪造签名。建议公钥通过 HTTPS 下发,或者在客户端做证书锁定。
第三,定期轮换密钥。不要一套密钥用到底。服务端可以维护多个密钥版本,客户端根据版本号选择对应的公钥。
第四,日志里不要打印密钥和明文。调试阶段很容易犯这个错误,上线前一定要清理。
踩过的坑:有一次调试签名验证,我把私钥打印到日志里方便对比,结果测试包发出去忘了删。虽然只是测试环境,但这是个非常危险的习惯。从那以后我养成了用
#if UNITY_EDITOR包裹所有调试日志的习惯。
5. 进阶应用与扩展思路
5.1 与 UnityWebRequest 结合做请求签名
实际项目中,RSA 签名往往和网络请求绑定。一个典型的流程是:客户端把请求参数按字典序拼接,加上时间戳和随机数,用私钥签名(如果是客户端签名场景)或者用服务端下发的公钥验签(如果是服务端签名场景),然后把签名放在请求头里。
public static string BuildSignedUrl(string baseUrl, Dictionary<string, string> parameters, string privatePem) { var sortedParams = parameters.OrderBy(p => p.Key) .Select(p => $"{p.Key}={p.Value}") .ToList(); string signContent = string.Join("&", sortedParams); string signature = SignWithPrivateKey(signContent, privatePem); parameters["sign"] = signature; string queryString = string.Join("&", parameters.Select(p => $"{p.Key}={UnityWebRequest.EscapeURL(p.Value)}")); return $"{baseUrl}?{queryString}"; }这里的关键是参数排序规则要和服务端完全一致,否则签名验证必然失败。我建议把排序和拼接逻辑写成一个共享的规范文档,前后端都按这个文档实现。
5.2 本地存档防篡改方案
单机游戏可以用 RSA 签名保护存档。做法是:游戏保存时,用内置的私钥对存档数据的哈希值签名,把签名和存档一起写入文件。读取时用公钥验签,如果验签失败说明存档被修改过。
但这里有个悖论:私钥在客户端,玩家依然可以提取私钥重新签名。所以这种方案只能防“小白修改”,防不了有心人。如果要真正防篡改,存档校验必须依赖服务端。
不过对于很多单机游戏来说,防小白已经够了。毕竟大部分玩家不会去反编译游戏提取私钥。这种方案的实现成本低,效果也还行。
5.3 性能优化建议
如果你的项目需要频繁做 RSA 操作,有几个优化方向:
- 缓存 RSA 实例:不要每次加解密都
RSA.Create(),可以复用一个静态实例。但要注意线程安全,RSA 实例不是线程安全的,多线程场景需要加锁或者用ThreadLocal。 - 异步执行:密钥生成和大量加解密操作放到
Task.Run里,避免阻塞主线程。 - 预计算:如果公钥固定,可以提前导入并缓存
RSAParameters,避免每次解析 PEM。 - 降级方案:对性能极度敏感的场景,考虑用 ECDSA 替代 RSA,椭圆曲线算法在相同安全强度下密钥更短、速度更快。
ECDSA 在 .NET 里也有支持,ECDsa.Create()的用法和 RSA 类似。不过跨平台兼容性需要额外测试,特别是和老服务端对接时。
5.4 密钥管理的工程化建议
最后聊一下密钥管理。小项目可能就是把 PEM 文件放在服务端某个目录里,但项目一大就会乱。我的建议是:
- 开发环境、测试环境、生产环境使用完全独立的密钥对
- 私钥文件权限设置为仅服务进程可读
- 密钥版本号纳入配置管理,支持灰度切换
- 建立密钥轮换流程,定期更换并保留旧密钥一段时间用于兼容
这些看起来是运维的事,但作为开发者,你在设计接口时就要把版本号、轮换机制考虑进去,不然后期改造会很痛苦。
我个人在实际操作中的体会是,RSA 本身不难,难的是跨平台兼容和跨语言对接。把填充模式、编码格式、密钥格式这三件事在项目初期就定死,写进接口文档,能省掉后面大量的联调时间。另外,能在服务端做的加密操作就不要放到客户端,客户端只做必要的签名和验签,这样既安全又省性能。