news 2026/10/15 22:41:20

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity中RSA加密与签名:公钥私钥双向应用实战

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。

处理方式有三种:

  1. 分段加密:把数据切成小块分别加密,然后拼接。但这样效率低,而且拼接后的密文长度会膨胀。
  2. 混合加密:用 RSA 加密一个 AES 密钥,用 AES 加密实际数据。这是标准做法。
  3. 只加密摘要:如果目的是完整性校验而非保密,只对数据的哈希值签名即可。

我一般推荐第二种。具体做法是:客户端生成一个随机的 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-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 本身不难,难的是跨平台兼容和跨语言对接。把填充模式、编码格式、密钥格式这三件事在项目初期就定死,写进接口文档,能省掉后面大量的联调时间。另外,能在服务端做的加密操作就不要放到客户端,客户端只做必要的签名和验签,这样既安全又省性能。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 22:39:55

CLI+SKILL:不懂代码也能用AI代理开发全栈应用?

昨天一个做运营的朋友问我&#xff1a;"我想做一个团队内部用的工具&#xff0c;能不能不从头学代码&#xff1f;"以前我只会说"有点难"&#xff1b;现在我告诉他&#xff0c;走CLI加SKILL这条路线&#xff0c;可以绕开手写代码这道坎。这篇文章想把我在实…

作者头像 李华
网站建设 2026/10/15 22:39:42

Qwen3.8 Flash Next从零部署:AutoDL算力、vLLM推理与Strata网关实战

这篇博文基于标题“从零开始部署Qwen3.8 Flash Next | AutoDL | Strata”进行合理演绎。由于用户未提供具体正文、关键词和摘要&#xff0c;我将以标题为核心&#xff0c;结合开源大模型部署、算力租赁平台和推理服务网关等常见实践场景&#xff0c;补全一个完整的技术实战教程…

作者头像 李华
网站建设 2026/10/15 22:39:25

MSP430G2553驱动DHT11时序精准控制实战

简介&#xff1a;本资源是一份面向嵌入式初学者与MSP430开发者的温湿度监测实践项目&#xff0c;聚焦于MSP430G2553单片机驱动DHT11传感器并实现12864液晶显示的完整实现方案。内容涵盖单总线通信协议解析、精确微秒级延时设计&#xff08;基于__delay_cycles&#xff09;、DHT…

作者头像 李华
网站建设 2026/10/15 22:37:45

增量式与绝对式编码器怎么选?以ERN1387和ECN1313为例

先给结论&#xff1a;ERN1387 和 ECN1313 放在一起比&#xff0c;最核心的差别不是价格&#xff0c;也不是分辨率&#xff0c;而是“增量式”和“绝对式”这两种完全不同的工作原理。很多人在工业编码器选型时&#xff0c;第一反应是翻样本上的每转脉冲数和位数&#xff0c;然后…

作者头像 李华
网站建设 2026/10/15 22:36:38

Blender 5.0界面布局优化:自定义窗口UI与工作流配置实战指南

作为一个常年在Blender里一泡就是一整天的人&#xff0c;我对窗口布局的执念可能比多数人深一点。默认的布局其实没毛病&#xff0c;但当你真正进入高强度建模或者展UV的环节&#xff0c;就会发现界面上每一个多余的光标、每一条过宽的面板都在偷偷吃掉你的注意力。所以当我看到…

作者头像 李华
网站建设 2026/10/15 22:36:27

C++代码复杂度分析:从量化指标到落地重构

有人提“C复杂度”时&#xff0c;很多人第一反应是“代码行数多”“函数太长”。实际上&#xff0c;我这些年跟各种老代码库打交道&#xff0c;发现真正伤人的从来不是行数&#xff0c;而是那种“不敢碰”的感觉&#xff1a;改一个分支&#xff0c;不知道踩了多少隐式依赖&…

作者头像 李华