首页 / 资讯中心 / 文章详情

Unity中RSA加密与签名:公钥私钥双向应用实战

Unity中RSA加密与签名:公钥私钥双向应用实战 ★ FEATURED ARTICLE
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.1RSACryptoServiceProvider和RSA.Create()都能用但某些平台比如 WebGL对加密库的支持有限制。这些坑我在第四节会集中讲。2. 核心原理与方案选型解析2.1 RSA 的数学基础用大白话讲RSA 的安全性建立在一个事实之上把两个大质数乘起来很容易但把乘积分解回两个质数极其困难。你选两个质数 p 和 q算出 n p × qn 就是模数。公钥是 (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老 APIWebGL 不支持RSA.Create().NET Standard 2.0需手动处理 PEM/DER跨平台推荐System.Security.Cryptography.RSA同上同上抽象基类实际返回平台实现第三方库如 BouncyCastle全平台PEM 直接读取体积大但兼容性最好我的建议是如果目标平台包含 WebGL直接用 BouncyCastle 或者自己写 PEM 解析。因为 WebGL 环境下RSACryptoServiceProvider直接抛异常而RSA.Create()在部分 Unity 版本里也有坑。如果只做 Android/iOS/PCRSA.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和加密的填充是两套体系不要搞混。哈希算法推荐 SHA256SHA1 已经不安全了。3.4 密钥长度与性能的实测数据我在一台中端 Android 手机上做了简单测试数据仅供参考密钥长度生成耗时加密耗时245字节内解密耗时签名耗时验签耗时1024 位约 80ms约 1ms约 2ms约 1ms约 1ms2048 位约 400ms约 2ms约 5ms约 2ms约 2ms4096 位约 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当前平台不支持该 APIWebGL 换 BouncyCastle 或服务端处理NullReferenceException密钥导入失败返回 null检查ImportSubjectPublicKeyInfo的返回值解密结果乱码编码不一致确认双方都用 UTF-84.4 安全实践中的几个关键注意点第一不要在客户端硬编码私钥。我见过有项目把私钥直接写在 C# 脚本里反编译后一目了然。私钥只能存在于服务端。第二公钥也要防篡改。如果攻击者能替换客户端的公钥就能伪造签名。建议公钥通过 HTTPS 下发或者在客户端做证书锁定。第三定期轮换密钥。不要一套密钥用到底。服务端可以维护多个密钥版本客户端根据版本号选择对应的公钥。第四日志里不要打印密钥和明文。调试阶段很容易犯这个错误上线前一定要清理。踩过的坑有一次调试签名验证我把私钥打印到日志里方便对比结果测试包发出去忘了删。虽然只是测试环境但这是个非常危险的习惯。从那以后我养成了用#if UNITY_EDITOR包裹所有调试日志的习惯。5. 进阶应用与扩展思路5.1 与 UnityWebRequest 结合做请求签名实际项目中RSA 签名往往和网络请求绑定。一个典型的流程是客户端把请求参数按字典序拼接加上时间戳和随机数用私钥签名如果是客户端签名场景或者用服务端下发的公钥验签如果是服务端签名场景然后把签名放在请求头里。public static string BuildSignedUrl(string baseUrl, Dictionarystring, 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 本身不难难的是跨平台兼容和跨语言对接。把填充模式、编码格式、密钥格式这三件事在项目初期就定死写进接口文档能省掉后面大量的联调时间。另外能在服务端做的加密操作就不要放到客户端客户端只做必要的签名和验签这样既安全又省性能。
阅读完成 · 觉得有帮助?
咨询建站