1. 为什么要在 C# 里把 RSA 双向加解密都跑通
RSA 这套非对称加密算法,做过服务端对接的人应该都不陌生。但很多同行在实际项目里只用了它的一半能力——要么只用公钥加密、私钥解密来传敏感数据,要么只用私钥签名、公钥验签来做身份确认。真正把“公钥加密私钥解密”和“私钥加密公钥解密”两个方向都在 C# 里跑通、并且稳定支持 2048 位密钥的完整实现,其实踩坑点比想象中多。
我最近在一个跨平台数据交换模块里就遇到了这个需求:一端是 C# 写的桌面客户端,另一端是另一套系统,双方约定用 RSA 2048 做双向加密通信。客户端要把本地采集的数据用对方给的公钥加密后发出去,同时还要能解开对方用私钥加密回来的指令。听起来简单,但真动手写的时候,填充模式、密钥格式、字节序、分段长度这些问题一个接一个冒出来。
这篇内容就是把我整个实现过程、参数选择依据、踩过的坑和最终可复现的方案完整梳理一遍。适合已经了解 C# 基础、需要在实际项目里落地 RSA 双向加解密的开发者,也适合刚接触非对称加密、想搞清楚“为什么这么写”的朋友。全文围绕 2048 位密钥展开,所有代码和参数都经过实际验证,可以直接拿去改改用。
2. RSA 双向加解密的核心原理与方案选型
2.1 公钥加密私钥解密与私钥加密公钥解密的本质区别
很多人第一次听到“私钥加密公钥解密”会觉得别扭,因为教科书上总说私钥是用来签名的。其实从数学角度看,RSA 的加解密就是一对互逆的模幂运算,公钥和私钥在运算层面是对称的——用其中一个加密,就能用另一个解密。所谓“私钥加密公钥解密”,在工程上通常就是签名与验签的另一种表述,只不过这里我们关注的是它作为“加密通道”的可行性。
两者的核心差异在于使用场景和安全性假设。公钥加密私钥解密,公钥可以公开分发,任何人拿到公钥都能加密,但只有持有私钥的一方能解密,适合“多方发给一方”的场景。私钥加密公钥解密,私钥只有一方持有,任何持有公钥的人都能解密,所以它不提供保密性,只提供来源确认——能解开就说明数据确实来自私钥持有者。在实际项目里,这两个方向经常配合使用:先用对方公钥加密自己的数据,再用自己的私钥对摘要做一次处理,实现既保密又防篡改。
注意:私钥加密公钥解密不能用来传真正需要保密的内容,因为公钥是公开的,谁都能解密。它的价值在于身份绑定和完整性校验。
2.2 为什么必须锁定 2048 位密钥
1024 位 RSA 在多年前就已经被认为不够安全,主流安全规范早就建议至少 2048 位。2048 位密钥的模数长度是 2048 比特,也就是 256 字节。这个长度直接决定了单次加密能处理的最大明文长度,也决定了密钥文件和密文的体积。
在 C# 里,RSACryptoServiceProvider默认可能生成 1024 位密钥,必须显式指定 2048。用RSACng或 .NET Core 之后的RSA.Create(2048)会更直观。选 2048 还有一个现实原因:大多数对接方、证书体系、硬件加密模块都以 2048 为默认档位,兼容性最好。再往上走 3072 或 4096,性能下降明显,而 2048 在当前算力水平下仍然足够安全。
2.3 填充模式的选择:PKCS#1 v1.5 还是 OAEP
这是 C# 实现 RSA 时最容易出问题的地方。RSACryptoServiceProvider默认用的是 PKCS#1 v1.5 填充,而很多现代系统默认用 OAEP。两者不兼容,一方用 v1.5 加密,另一方用 OAEP 解密,必然报“解码错误”或“参数不正确”。
PKCS#1 v1.5 的优点是兼容性极好,老系统、Java 默认实现、很多硬件设备都支持。缺点是存在一些理论上的填充攻击风险,但在正确实现和配合完整性校验的前提下,实际项目里仍然大量使用。OAEP 更安全,但需要双方都支持,而且 OAEP 的哈希算法(SHA-1 或 SHA-256)也要一致。
我的选择是:如果对接方没有特别要求,优先用 OAEP with SHA-256;如果对方是老系统或明确要求 v1.5,就用 v1.5。关键是一开始就确认清楚,不要等联调时才发现。
| 填充模式 | 兼容性 | 安全性 | 单次最大明文(2048位) |
|---|---|---|---|
| PKCS#1 v1.5 | 极好 | 一般 | 245 字节 |
| OAEP SHA-1 | 好 | 较好 | 214 字节 |
| OAEP SHA-256 | 较好 | 好 | 190 字节 |
2.4 密钥格式:XML、PEM 还是 DER
C# 原生RSACryptoServiceProvider用 XML 格式导入导出密钥,ToXmlString和FromXmlString是最方便的。但跨平台对接时,对方给的往往是 PEM 格式(Base64 文本,带-----BEGIN PUBLIC KEY-----头尾)或 DER 二进制。.NET Core 之后提供了ImportSubjectPublicKeyInfo和ImportPkcs8PrivateKey等方法,可以直接处理这些格式。
我的建议是:内部 C# 之间通信用 XML 最省事;跨语言、跨平台通信用 PEM,然后在 C# 里做一次格式转换。转换的核心是把 PEM 的 Base64 内容解出来,去掉头尾和换行,再按 ASN.1 结构解析。.NET 5+ 已经内置了ImportFromPem,省了很多事。
3. C# 实现 RSA 2048 双向加解密的完整实操
3.1 密钥对生成与保存的正确姿势
先生成一对 2048 位密钥。用RSA.Create(2048)是最简洁的方式,它返回一个RSA实例,底层在 Windows 上通常是RSACng,在 Linux 上是 OpenSSL 实现。生成后立刻导出公钥和私钥,私钥一定要妥善保存。
using System.Security.Cryptography; // 生成 2048 位密钥对 using var rsa = RSA.Create(2048); // 导出公钥(PEM 格式,SubjectPublicKeyInfo) string publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem(); // 导出私钥(PEM 格式,PKCS#8) string privateKeyPem = rsa.ExportPkcs8PrivateKeyPem(); // 如果需要 XML 格式(兼容老代码) string publicKeyXml = rsa.ToXmlString(false); string privateKeyXml = rsa.ToXmlString(true);这里有个细节:ToXmlString(true)导出的私钥包含所有参数(Modulus、Exponent、P、Q、DP、DQ、InverseQ、D),而 PEM 的 PKCS#8 格式更紧凑。如果只是自己系统内部用,XML 足够;如果要和别的语言对接,PEM 更通用。
实操心得:私钥文件千万不要提交到代码仓库。我习惯把私钥放在独立的配置文件或密钥管理服务里,代码里只引用路径或环境变量。曾经见过同事把私钥硬编码在源码里,后来代码泄露,整套加密形同虚设。
3.2 公钥加密私钥解密的代码实现
这是最常用的方向。假设我们已经有了对方的公钥 PEM,要加密一段明文发过去。
public static byte[] EncryptWithPublicKey(string plainText, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); byte[] data = Encoding.UTF8.GetBytes(plainText); // 使用 OAEP SHA-256 填充 return rsa.Encrypt(data, RSAEncryptionPadding.OaepSHA256); } public static string DecryptWithPrivateKey(byte[] cipherData, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); byte[] decrypted = rsa.Decrypt(cipherData, RSAEncryptionPadding.OaepSHA256); return Encoding.UTF8.GetString(decrypted); }如果对方要求 PKCS#1 v1.5,把RSAEncryptionPadding.OaepSHA256换成RSAEncryptionPadding.Pkcs1即可。但要注意,RSAEncryptionPadding.Pkcs1在 .NET 里对应的是 PKCS#1 v1.5。
这里的关键点是:加密和解密必须使用完全相同的填充模式。我遇到过联调时对方用 Java 的Cipher.getInstance("RSA/ECB/PKCS1Padding"),我这边用 OAEP,结果一直解密失败,排查了半天才发现是填充模式不一致。
3.3 私钥加密公钥解密的代码实现
这个方向在 .NET 里稍微绕一点,因为RSA.Encrypt方法默认是用公钥加密。要用私钥“加密”,实际上要用SignData或者手动用私钥做模幂运算。但更常见的做法是:用私钥对数据进行签名,对方用公钥验签。如果业务上确实需要“私钥加密后公钥能解密出原文”,可以用RSACryptoServiceProvider的Encrypt配合私钥参数,但这种方式在 .NET Core 里支持有限。
更稳妥的方案是使用RSA.SignData做签名,对方用VerifyData验签。如果对方系统确实要求“私钥加密、公钥解密”这种模式,通常他们指的是 PKCS#1 v1.5 的签名机制,只是表述不同。
public static byte[] SignWithPrivateKey(string data, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); byte[] dataBytes = Encoding.UTF8.GetBytes(data); return rsa.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } public static bool VerifyWithPublicKey(string data, byte[] signature, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); byte[] dataBytes = Encoding.UTF8.GetBytes(data); return rsa.VerifyData(dataBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); }如果对方坚持要“私钥加密出密文、公钥解密出明文”,那需要双方约定用原始 RSA 运算,但这会带来严重的安全隐患,因为 PKCS#1 v1.5 加密填充在私钥侧使用是不标准的。我的建议是:和对方沟通清楚,把“私钥加密公钥解密”统一到签名验签的语义上,这样既安全又标准。
注意:不要试图用
RSACryptoServiceProvider.Encrypt配合私钥去实现“私钥加密”,这在 .NET Core 和 .NET 5+ 上行为不一致,而且容易出错。签名验签才是正道。
3.4 分段加密:处理超过单次长度限制的明文
2048 位密钥用 OAEP SHA-256 时,单次最多加密 190 字节。如果明文超过这个长度,必须分段。分段逻辑不复杂,但要注意每段加密后的密文长度是固定的 256 字节,解密时按 256 字节切分。
public static byte[] EncryptLongData(byte[] data, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); int maxBlockSize = 190; // OAEP SHA-256 with 2048-bit key var output = new List<byte>(); for (int i = 0; i < data.Length; i += maxBlockSize) { int len = Math.Min(maxBlockSize, data.Length - i); byte[] block = new byte[len]; Array.Copy(data, i, block, 0, len); byte[] encryptedBlock = rsa.Encrypt(block, RSAEncryptionPadding.OaepSHA256); output.AddRange(encryptedBlock); } return output.ToArray(); } public static byte[] DecryptLongData(byte[] cipherData, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); int blockSize = 256; // 2048-bit key = 256 bytes var output = new List<byte>(); for (int i = 0; i < cipherData.Length; i += blockSize) { byte[] block = new byte[blockSize]; Array.Copy(cipherData, i, block, 0, blockSize); byte[] decryptedBlock = rsa.Decrypt(block, RSAEncryptionPadding.OaepSHA256); output.AddRange(decryptedBlock); } return output.ToArray(); }分段时有个坑:如果明文长度刚好是 190 的整数倍,最后一段会是完整的 190 字节,没问题。但如果最后一段不足 190 字节,加密后仍然是 256 字节,解密时按 256 切分不会出错。真正需要注意的是密文总长度必须是 256 的整数倍,否则说明数据在传输中损坏了。
4. 实际项目中遇到的典型问题与排查记录
4.1 填充模式不匹配导致的“解码错误”
这是最高频的问题。表现是解密时抛出CryptographicException: 解码错误或参数不正确。原因几乎都是加密方和解密方用了不同的填充模式。排查方法很简单:确认双方代码里Encrypt和Decrypt的第二个参数是否一致。
我整理了一个速查表:
| 异常信息 | 可能原因 | 解决方法 |
|---|---|---|
| 解码错误 | 填充模式不一致 | 统一为 Pkcs1 或 OaepSHA256 |
| 参数不正确 | 密钥格式错误 | 检查 PEM 头尾和 Base64 内容 |
| 长度错误 | 密文被截断 | 确认密文长度是 256 的整数倍 |
| 不支持的填充 | .NET 版本过低 | 升级到 .NET Core 3.0+ |
4.2 密钥格式转换的常见陷阱
对方给的公钥可能是-----BEGIN RSA PUBLIC KEY-----开头,这是 PKCS#1 格式,而 .NET 的ImportFromPem默认期望-----BEGIN PUBLIC KEY-----(SubjectPublicKeyInfo)。两者不兼容,直接导入会报错。
解决办法是用ImportRSAPublicKey处理 PKCS#1,或者先做一次格式转换。私钥同理,-----BEGIN RSA PRIVATE KEY-----是 PKCS#1,-----BEGIN PRIVATE KEY-----是 PKCS#8。.NET 提供了对应的ImportRSAPrivateKey和ImportPkcs8PrivateKey。
// 处理 PKCS#1 公钥 rsa.ImportRSAPublicKey(Convert.FromBase64String(base64Content), out _); // 处理 PKCS#8 私钥 rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(base64Content), out _);实操心得:拿到对方的密钥后,先看头尾标识,再决定用哪个导入方法。我习惯写一个辅助方法,自动识别头尾并调用对应的导入接口,省得每次手动判断。
4.3 跨平台换行符与 Base64 解码问题
PEM 文件里的 Base64 内容通常每 64 个字符换一行。在 Windows 上换行是\r\n,在 Linux 上是\n。如果直接按行拼接,可能残留\r,导致 Base64 解码失败。稳妥的做法是先把所有空白字符去掉,再解码。
string base64 = pemContent .Replace("-----BEGIN PUBLIC KEY-----", "") .Replace("-----END PUBLIC KEY-----", "") .Replace("\r", "") .Replace("\n", "") .Trim(); byte[] keyBytes = Convert.FromBase64String(base64);这个细节看起来小,但在跨平台对接时非常容易翻车。我曾经因为一个\r字符排查了两个小时,最后发现是对方在 Windows 上生成的 PEM 文件。
4.4 性能考量:什么时候不该用 RSA 加密大数据
RSA 的运算速度比对称加密慢几个数量级。2048 位密钥做一次加密大约需要几十微秒到几百微秒,看起来不多,但如果要加密几 MB 的数据,分段后会有上万次 RSA 运算,耗时会非常可观。
正确的做法是:用 RSA 加密一个对称密钥(比如 AES 密钥),然后用 AES 加密实际数据。这就是混合加密体系。RSA 只负责保护那个短密钥,大数据交给 AES。如果项目里确实需要加密大量数据,一定要考虑这个方案,而不是硬用 RSA 分段。
5. 完整可复现的封装类与使用示例
5.1 封装一个 RsaHelper 类
把上面的逻辑整理成一个静态帮助类,方便项目里直接调用。这个类包含密钥生成、导入导出、双向加解密、分段处理等核心方法。
public static class RsaHelper { public static (string PublicKey, string PrivateKey) GenerateKeyPair(int keySize = 2048) { using var rsa = RSA.Create(keySize); return (rsa.ExportSubjectPublicKeyInfoPem(), rsa.ExportPkcs8PrivateKeyPem()); } public static byte[] EncryptWithPublicKey(byte[] data, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); return EncryptLongData(data, rsa, RSAEncryptionPadding.OaepSHA256); } public static byte[] DecryptWithPrivateKey(byte[] cipherData, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); return DecryptLongData(cipherData, rsa, RSAEncryptionPadding.OaepSHA256); } public static byte[] SignWithPrivateKey(byte[] data, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); return rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } public static bool VerifyWithPublicKey(byte[] data, byte[] signature, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } private static byte[] EncryptLongData(byte[] data, RSA rsa, RSAEncryptionPadding padding) { int maxBlock = GetMaxBlockSize(rsa.KeySize, padding); var output = new List<byte>(); for (int i = 0; i < data.Length; i += maxBlock) { int len = Math.Min(maxBlock, data.Length - i); byte[] block = new byte[len]; Array.Copy(data, i, block, 0, len); output.AddRange(rsa.Encrypt(block, padding)); } return output.ToArray(); } private static byte[] DecryptLongData(byte[] cipherData, RSA rsa, RSAEncryptionPadding padding) { int blockSize = rsa.KeySize / 8; var output = new List<byte>(); for (int i = 0; i < cipherData.Length; i += blockSize) { byte[] block = new byte[blockSize]; Array.Copy(cipherData, i, block, 0, blockSize); output.AddRange(rsa.Decrypt(block, padding)); } return output.ToArray(); } private static int GetMaxBlockSize(int keySize, RSAEncryptionPadding padding) { int byteSize = keySize / 8; if (padding == RSAEncryptionPadding.Pkcs1) return byteSize - 11; if (padding == RSAEncryptionPadding.OaepSHA1) return byteSize - 42; if (padding == RSAEncryptionPadding.OaepSHA256) return byteSize - 66; return byteSize - 66; } }5.2 调用示例与验证结果
用这个帮助类跑一遍完整流程,验证双向加解密都能正常工作。
var (pub, priv) = RsaHelper.GenerateKeyPair(2048); string original = "这是一段需要加密的测试数据,长度超过190字节时会自动分段处理。"; byte[] data = Encoding.UTF8.GetBytes(original); // 公钥加密,私钥解密 byte[] encrypted = RsaHelper.EncryptWithPublicKey(data, pub); byte[] decrypted = RsaHelper.DecryptWithPrivateKey(encrypted, priv); string result = Encoding.UTF8.GetString(decrypted); Console.WriteLine(result == original ? "公钥加密私钥解密:通过" : "失败"); // 私钥签名,公钥验签 byte[] signature = RsaHelper.SignWithPrivateKey(data, priv); bool valid = RsaHelper.VerifyWithPublicKey(data, signature, pub); Console.WriteLine(valid ? "私钥签名公钥验签:通过" : "失败");实测下来,2048 位密钥、OAEP SHA-256 填充,加密 1KB 数据大约需要 6 次分段,总耗时在 1 毫秒以内。签名验签更快,因为只对摘要做一次 RSA 运算。
5.3 密钥管理与轮换的实用建议
密钥不能一直用同一对。我的做法是给密钥加一个版本号,配置文件里记录当前使用的版本和对应的公私钥。轮换时新数据用新密钥,旧数据仍然能用旧密钥解密。这样既保证了安全性,又不会因为轮换导致历史数据无法读取。
另外,私钥的存储要格外小心。在 Windows 上可以用 DPAPI 保护,在 Linux 上可以用文件权限控制。如果项目规模较大,建议接入专门的密钥管理服务,而不是把私钥散落在各个配置文件里。
6. 几个容易被忽略的细节与个人经验
6.1 关于RSACryptoServiceProvider和RSA.Create的选择
老代码里大量使用RSACryptoServiceProvider,它依赖 Windows 的 CryptoAPI,在 .NET Core 跨平台时会有兼容问题。新项目一律用RSA.Create(),它会根据运行平台自动选择最优实现。如果必须兼容老代码,可以在 Windows 上继续用RSACryptoServiceProvider,但要注意它的默认填充是 PKCS#1 v1.5,而且不支持 OAEP SHA-256 的某些重载。
6.2 密文传输时的编码问题
RSA 加密出来的是二进制字节数组,如果要通过 JSON、HTTP 表单或文本协议传输,必须做 Base64 编码。解码时用Convert.FromBase64String。这里有个小坑:Base64 编码后的字符串可能包含+、/、=这些字符,在 URL 里传输时需要做 URL 编码,或者改用 Base64URL 变体。
6.3 调试时如何快速定位问题
我的习惯是写一个最小复现单元测试,固定密钥、固定明文、固定填充模式,先确保单次加密解密能通过,再逐步增加数据长度和分段逻辑。如果联调时对方解不开,先让对方用同样的密钥和填充模式在本地跑一遍我的测试向量,这样能快速排除是密钥问题还是代码问题。
最后分享一个小技巧:在日志里记录加密前后的数据长度和填充模式,但千万不要记录明文和私钥。我曾经在排查问题时不小心把明文打进了日志,后来做安全审计时被指出来,虽然没造成实际泄露,但确实是个隐患。日志里只记长度、时间戳、填充模式这些元信息就够了。