ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#实现国密SM2加解密与签名:从库选型到跨语言互操作

2026/9/1 17:17:36 拓冰建站 浏览量
C#实现国密SM2加解密与签名:从库选型到跨语言互操作 简介一套面向 C# 开发者的国密算法 SM2 实现示例适合需要集成国产密码算法、从事信息安全或政务/金融系统开发的程序员。代码演示了 SM2 密钥对生成、公钥获取与 Hex 编码输出等核心操作可在 Visual Studio 中直接打开运行便于快速理解调用流程。资源以 rar 压缩包发布共 37 个文件大小 6.85MB以 C# 源文件为主配合项目配置文件、动态库、可执行文件及 XML 说明等构成一个完整可编译的 WinForms 工程结构清晰适合二次修改。已有 1510 人学习下载具备一定参考热度。通过该资源读者能获得可直接运行的 SM2 示例工程包含界面测试入口、密钥对生成与编码输出逻辑、加解密相关源码可帮助开发者节省从零搭建环境的时间快速移植到实际项目。 去年有个老客户找我说他们一套用C#写的WPF上位机要过等保测评原来用的RSA得换成国密SM2。我起初觉得替换个算法库而已结果真动手才发现SM2跟RSA在C#里的落地完全是两个世界曲线参数要自己养、密文格式有讲究、签名输出还能给你整出DER和裸64字节两种形态。这篇就把我在C#里折腾SM2的完整过程摊开讲从库选型、密钥生成、加解密、签名验签到跨语言互操作的那些坑一次说透。如果你也要在C#项目里对接国密需求或者只是想把SM2的原理搞清楚这篇应该能帮你少走不少弯路。1. 项目还没动工我就先和对方确认了三件事接手这类需求我建议先别急着写代码。SM2跟RSA有个很大的不同RSA在Java、C#、Go里实现出来基本形态一致而SM2在不同语言、不同库里的默认行为差别很大联调阶段最容易翻车。我每次都会先跟对接方敲定三件事。1.1 加密还是签名两种用法别混SM2本身是一套椭圆曲线公钥密码体系既能做加密解密也能做数字签名但这两套流程在代码里走的是完全不同的类和方法。很多人以为支持SM2就全包了实际上加密引擎和签名引擎是两码事初始化参数、输出格式都不同。我见过一个项目前端用SM2加密了一段数据传后端后端却用签名验签的接口去解折腾半天才发现用法搞反了。所以动工前第一件事明确你负责的是加密链路还是签名链路或者两者都要做。1.2 密钥格式和密文编码模式第二件要确认的事是密钥和密文的编码格式。私钥是16进制字符串还是Base64公钥是04 X Y的65字节形式还是不带04的64字节裸坐标形式密文按C1C2C3还是C1C3C2拼接这三问是联调现场争吵率最高的三个问题。尤其是C1C3C2和C1C2C3国密标准原文写的是C1C2C3但很多库比如Java系的BouncyCastle默认输出C1C3C2两种模式不能混用一旦对接方用了不同模式解密出来的就是乱码。1.3 跨语言联调时谁说了算如果对接的是Java后端、C加密机或者某云厂商的国密网关一定要拿到对方的接入文档最好让对方给一组已知答案的测试向量包含密钥、明文、密文、签名提前在本地复现一遍。我在一个项目里就是没先要测试向量凭感觉写完了C#端的SM2加解密结果跟Java那边联调时才发现两边对密文模式的理解完全不同光排查这个问题就花了两天。先要测试数据这是过来人给你的最直接建议。2. 动手写码前把SM2这几个关键参数刻在脑子里SM2刚接触时感觉挺玄乎但拆开看核心就是一套固定的椭圆曲线参数加几个运算规则。理解这些后面写代码才不会知其然而不知其所以然。2.1 曲线参数与密钥长度SM2使用的推荐曲线叫sm2p256v1曲线方程是y² x³ ax b基于一个256位的素数域。这就决定了SM2的密钥长度是256位也就是32字节。私钥一个256位的随机整数32字节公钥曲线上一个点包含X和Y两个32字节坐标完整形式是04 X(32) Y(32)共65字节注意私钥不是越随机越好而是要在[1, n-2]区间内均匀随机选取其中n是曲线的阶。这个区间范围所有成熟的库都会帮你处理但如果你自己写密钥生成代码一定要确保随机源足够安全别用Random这类伪随机数生成器。2.2 加解密的本质是临时密钥异或SM2加密的流程可以用一句话概括生成一个临时私钥用它算出一个共享点再用共享点去加密数据。具体分四步生成临时私钥k计算C1 k * GG是曲线基点C1就是一个曲线点标准做法是取65字节非压缩形式。计算共享点S k * P其中P是接收方公钥。这个共享点只有发送方和接收方能算出来因为接收方用自己的私钥乘C1也能得到同一个点。从共享点的X坐标派生密钥流与明文做异或得到密文C2。对明文做SM3哈希得到杂凑值C3用于完整性校验。最终密文C C1 || C2 || C3或者C1 || C3 || C2看模式。这里有个密码学上的巧妙设计异或加密本质上是对称加密但加密所需的密钥流是通过椭圆曲线协商出来的所以不需要提前共享密钥。这也是SM2最有价值的地方。密文长度有个很直观的规律对任意长度的明文lenSM2加密后密文长度是len 97字节C1的65字节加C3的32字节。如果明文很短会发现密文膨胀得很厉害这是正常的。2.3 签名验签和64字节签名输出的来源热搜词里有个高频问题SM2数字签名算法的输出的签名长度是多少。直接给答案裸签名是64字节R和 S各32字节如果用DER/ASN.1编码一般会变成70字节左右。为什么是64字节因为SM2签名算法最后输出一对大整数(r, s)每个都是256位所以各占32字节。这两段是直接拼接的r || s。但很多密码学库不会直接返回裸64字节而是把r和s打包成一个ASN.1的SEQUENCE结构导致字符串看起来更长。这是让新手最懵的地方——我在第4章专门讲怎么转换。签名的验签过程会重新计算一个值与r比对这个r跟消息、公钥、随机数都有关系具体公式这里不展开你只需要记住SM2签名必须指定用户ID默认值是1234567812345678这串16字节的ASCII字符串。Z值计算和用户ID绑定这也是联调时最隐蔽的坑之一。3. C#里的库选择与跑通SM2加解密原理过完开始写代码。C#生态里做国密可选项其实不多我直接把选型思路和完整代码放出来。3.1 为什么是BouncyCastleC#里做SM2目前最稳妥的方案就是用BouncyCastle的C#移植版NuGet包名叫BouncyCastle.Cryptography老版叫BouncyCastle。我推荐的三个理由API覆盖全SM2加解密、SM2签名验签、SM3、SM4全家桶都有不用再引别的库。跨平台支持Windows/Linux/macOS在.NET Framework和.NET 6上都能跑。社区验证充分它是Java版BouncyCastle的移植Java生态里国密方案基本都绕不开它C#这边也积累了大量的踩坑资料。不推荐自己写椭圆曲线密码学里的侧信道防护、随机数生成、常数时间比较每一个都是安全敏感点自己做容易出大事。安装命令dotnet add package BouncyCastle.Cryptography3.2 生成密钥对的完整代码下面这段代码直接生成SM2密钥对并输出16进制形式的私钥和公钥。using Org.BouncyCastle.Asn1.GM; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Math.EC; using Org.BouncyCastle.Security; using System.Text; public static class Sm2KeyGenerator { public static (string privateKeyHex, string publicKeyHex) GenerateKeyPair() { // 1. 获取SM2推荐曲线 X9ECParameters x9 GMNamedCurves.GetByName(sm2p256v1); ECDomainParameters domain new ECDomainParameters( x9.Curve, x9.G, x9.N, x9.H); // 2. 初始化密钥对生成器 ECKeyPairGenerator generator new ECKeyPairGenerator(); ECKeyGenerationParameters keyGenParams new ECKeyGenerationParameters(domain, new SecureRandom()); generator.Init(keyGenParams); // 3. 生成密钥对 AsymmetricCipherKeyPair keyPair generator.GenerateKeyPair(); ECPrivateKeyParameters privateKey (ECPrivateKeyParameters)keyPair.Private; ECPublicKeyParameters publicKey (ECPublicKeyParameters)keyPair.Public; // 4. 转成十六进制字符串 // 私钥是32字节用ToByteArrayUnsigned避免出现前导00 string privateKeyHex Hex.Encode(privateKey.D.ToByteArrayUnsigned()); // 公钥按非压缩形式输出65字节带04前缀 byte[] publicKeyBytes publicKey.Q.GetEncoded(false); string publicKeyHex Hex.Encode(publicKeyBytes); return (privateKeyHex, publicKeyHex); } }公钥按false参数输出得到的就是04 X Y的65字节形式这也是大多数对接方默认接受的格式。如果你的对接方只要64位裸坐标那就去掉开头的04即可。3.3 加密解密真正落地加解密的核心是用SM2Engine注意构造函数里要指定模式。我一般统一用C1C3C2因为这是BouncyCastle各语言版本里兼容性最好的默认模式。using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Asn1.GM; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Math.EC; using Org.BouncyCastle.Security; public static class Sm2Crypto { private static ECDomainParameters GetDomain() { var x9 GMNamedCurves.GetByName(sm2p256v1); return new ECDomainParameters(x9.Curve, x9.G, x9.N, x9.H); } // 加密传入公钥Hex和明文输出密文Hex public static string Encrypt(string publicKeyHex, string plainText) { var domain GetDomain(); // 从Hex还原公钥点 byte[] publicKeyBytes Hex.Decode(publicKeyHex); ECPoint point domain.Curve.DecodePoint(publicKeyBytes); ECPublicKeyParameters publicKeyParams new ECPublicKeyParameters(point, domain); // 初始化SM2Engine指定C1C3C2模式 SM2Engine engine new SM2Engine(SM2Engine.Mode.C1C3C2); engine.Init(true, new ParametersWithRandom(publicKeyParams, new SecureRandom())); byte[] plainBytes Encoding.UTF8.GetBytes(plainText); byte[] cipherBytes engine.ProcessBlock(plainBytes, 0, plainBytes.Length); return Hex.Encode(cipherBytes); } // 解密传入私钥Hex和密文Hex输出明文 public static string Decrypt(string privateKeyHex, string cipherTextHex) { var domain GetDomain(); BigInteger d new BigInteger(1, Hex.Decode(privateKeyHex)); ECPrivateKeyParameters privateKeyParams new ECPrivateKeyParameters(d, domain); SM2Engine engine new SM2Engine(SM2Engine.Mode.C1C3C2); engine.Init(false, privateKeyParams); byte[] cipherBytes Hex.Decode(cipherTextHex); byte[] plainBytes engine.ProcessBlock(cipherBytes, 0, cipherBytes.Length); return Encoding.UTF8.GetString(plainBytes); } }这里有个关键点加密的时候Init的第一个参数是true内部会自动生成临时密钥对你传入的ParametersWithRandom是接收方公钥加一个安全随机源解密的时候Init的第一个参数是false直接用私钥就能从C1恢复出共享点。我实测过用上面的代码加解密性能在毫秒级对接绝大多数业务系统都够用。4. 签名验签代码与跨语言签名格式的坑签名验签是另一个高频需求也是互操作问题最集中的地方。4.1 SM2Signer的用法BouncyCastle的SM2Signer封装了SM2签名和验签的完整流程用起来不算复杂但是初始化参数里必须带上用户ID。using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Crypto.Signers; using Org.BouncyCastle.Asn1.GM; using Org.BouncyCastle.Security; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Math.EC; using System.Text; public static class Sm2SignerHelper { private static readonly byte[] DefaultUserId Encoding.ASCII.GetBytes(1234567812345678); private static ECDomainParameters GetDomain() { var x9 GMNamedCurves.GetByName(sm2p256v1); return new ECDomainParameters(x9.Curve, x9.G, x9.N, x9.H); } // 签名返回DER编码的签名Hex public static string Sign(string privateKeyHex, string data) { var domain GetDomain(); BigInteger d new BigInteger(1, Hex.Decode(privateKeyHex)); ECPrivateKeyParameters privateKeyParams new ECPrivateKeyParameters(d, domain); SM2Signer signer new SM2Signer(); signer.Init(true, new ParametersWithID( new ParametersWithRandom(privateKeyParams, new SecureRandom()), DefaultUserId)); byte[] dataBytes Encoding.UTF8.GetBytes(data); signer.BlockUpdate(dataBytes, 0, dataBytes.Length); byte[] derSignature signer.GenerateSignature(); return Hex.Encode(derSignature); } // 验签传入DER编码的签名Hex public static bool Verify(string publicKeyHex, string data, string signatureHex) { var domain GetDomain(); byte[] publicKeyBytes Hex.Decode(publicKeyHex); ECPoint point domain.Curve.DecodePoint(publicKeyBytes); ECPublicKeyParameters publicKeyParams new ECPublicKeyParameters(point, domain); SM2Signer signer new SM2Signer(); signer.Init(false, new ParametersWithID(publicKeyParams, DefaultUserId)); byte[] dataBytes Encoding.UTF8.GetBytes(data); byte[] signatureBytes Hex.Decode(signatureHex); signer.BlockUpdate(dataBytes, 0, dataBytes.Length); return signer.VerifySignature(signatureBytes); } }4.2 DER签名和64字节裸签名的转换上面代码里GenerateSignature()返回的其实是DER编码的ASN.1结构所以签名Hex长度大概是140个字符左右对应约70字节。如果你的对接方要求裸64字节签名也就是r || s直接拼接就要做一次转换using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Math; public static byte[] DerToPlain(byte[] derSignature) { Asn1Sequence seq (Asn1Sequence)Asn1Object.FromByteArray(derSignature); BigInteger r ((DerInteger)seq[0]).Value; BigInteger s ((DerInteger)seq[1]).Value; // r和s各补足32字节 byte[] rBytes ToFixedLength(r, 32); byte[] sBytes ToFixedLength(s, 32); byte[] result new byte[64]; Buffer.BlockCopy(rBytes, 0, result, 0, 32); Buffer.BlockCopy(sBytes, 0, result, 32, 32); return result; } private static byte[] ToFixedLength(BigInteger value, int length) { byte[] raw value.ToByteArrayUnsigned(); if (raw.Length length) return raw; byte[] padded new byte[length]; if (raw.Length length) { Buffer.BlockCopy(raw, 0, padded, length - raw.Length, raw.Length); } else { // 理论上r和s不会超过32字节但留个保护 Buffer.BlockCopy(raw, raw.Length - length, padded, 0, length); } return padded; }反过来如果对方给你64字节裸签名你要验签就得先包装成DERpublic static byte[] PlainToDer(byte[] plainSignature) { byte[] rBytes new byte[32]; byte[] sBytes new byte[32]; Buffer.BlockCopy(plainSignature, 0, rBytes, 0, 32); Buffer.BlockCopy(plainSignature, 32, sBytes, 0, 32); DerInteger r new DerInteger(new BigInteger(1, rBytes)); DerInteger s new DerInteger(new BigInteger(1, sBytes)); DerSequence seq new DerSequence(new Asn1Encodable[] { r, s }); return seq.GetDerEncoded(); }4.3 用户ID不一致导致验签失败的案例这是我真实踩过的坑。有一次C#端验签一直失败密钥对、签名数据、流程全排查了一遍最后才发现问题是用户ID不一致——对方Java系统用的是自定义用户ID长度是15字节而我这边用的是默认的1234567812345678由于SM2的Z值计算把用户ID也纳入了哈希用户ID不同最终验签结果完全不同。所以联调前一定要确认用户ID。多数系统会用默认值但也有不少会自定义。这个参数看着不起眼错了就是全军覆没。5. 国密改造中绕不开的密钥管理问题代码跑通只是第一步工程落地还有一道坎密钥从哪来、怎么存、怎么轮换。5.1 私钥别放在配置里我见到太多人把SM2私钥以明文Hex字符串写在appsettings.json里这在严格的安全评审里是过不去的。私钥等同于数字世界的身份明文存储相当于把保险柜钥匙挂在门上。C#服务端做国密改造优先考虑KMS/HSM把私钥托管到云KMS或密码机服务端只持有密钥ID加解密、签名操作通过接口调用私钥永远不出硬件。这是最推荐的方案。Windows证书存储如果场景简单可以导入证书存储区至少比配置文件强。加密配置文件实在不行才用DPAPI或对称加密把私钥加密后放配置文件但这只是及格不是优秀。5.2 密钥版本号和轮换策略热搜词里那句后端sm2密钥须固化进hsm/kms支持轮换与版本号其实是生产环境的真实刚需。密钥不能一把钥匙用到天荒地老轮换周期最好不超过一年敏感场景半年一次。轮换时注意几个细节密文要能解老密钥加密的数据在新密钥上线后还要能解密。方案是解密时先按版本号找到对应私钥而不是默认用最新私钥解所有历史数据。签名要带版本签名结果里带上密钥版本标识验签方才能知道用哪把公钥验。公钥分发要同步轮换不是只换私钥公钥也要同步更新到所有调用方。这块建议在系统设计阶段就做好不然密钥一换历史数据全解不开那时候才是真正的灾难。6. 我从SM2项目里总结出的几条经验最后说点虚的但很重要的东西这些经验花了不少冤枉钱才学到的。6.1 先定规范再写代码国密联调最大的问题不是算法本身而是双方对规则的预期不一致。我建议任何SM2项目启动时先输出一份对接约定文档里面至少包含约定项推荐值密文模式C1C3C2公钥格式04前缀65字节Hex私钥格式32字节Hex签名格式明确是DER还是裸64字节用户ID默认1234567812345678Hex大小写统一小写把这个表格发给所有对接方让各方按同一标准实施能省下大量联调时间。6.2 测试数据要留档每次联调把成功和失败的测试样例密钥、明文、密文、签名存档。一方面方便回归测试另一方面出了问题可以快速定位是哪一端改了逻辑。6.3 性能实测与并发注意SM2加密和签名性能虽然不错但每个操作都涉及椭圆曲线点乘运算在高并发场景下CPU消耗不能忽视。我在一个网关项目里压测过单线程每秒大概能执行几百次SM2签名但并发到几十线程时CPU会明显飙高需要评估是否要前置缓存或者控制调用频率。另外BouncyCastle的SecureRandom初始化在第一次调用时相对较慢建议在服务启动时做一次预热避免第一个请求因为随机数初始化而超时。这是很小的细节但真正上线时可能救你一命。最后再说一下我现在的习惯只要项目涉及SM2我都会在方案设计阶段把模式、格式、用户ID这三样钉死并且先要一组测试向量验证环境。这三步走完后面基本就是写业务逻辑的事了。希望这篇能把你在C#里做国密SM2的那些坎提前填平。本文还有配套的精品资源点击获取