简介:这是一套面向.NET开发者与信息安全学习者的国密算法C#实现工程,围绕SM2、SM3、SM4三类国密算法提供可运行的WinForm示例与源码参考,适合课程实验、接口联调前的算法验证及国产密码应用入门。压缩包共51个文件,约5.33MB,以21个cs源码、5个resx与5个resources界面资源、4个dll依赖及2个exe程序为主,并含解决方案、项目文件与配置说明,便于打开工程查看算法调用、界面交互和依赖组织。已有1316人学习下载,在国密C#实践场景中具有一定参考价值。读者可了解SM2加解密与签名验签、SM3摘要、SM4对称加解密的代码组织方式,结合窗体模块对照调试,快速搭建国密算法测试程序,并借鉴BouncyCastle依赖的使用与项目分层思路。
1. 接口联调前两天才拿到文档:一个纯 C# 国密三件套能救什么场
接口联调前两天才拿到对接文档,签名那一栏写着 SM2,摘要写着 SM3,报文加密写着 SM4——一个标准的国密三件套改造需求,落在了一个纯 .NET 后端团队头上。翻了一圈包管理器,能做全的要么依赖本地 C 库、部署时还要带一堆动态库,要么只给了 SM3 和 SM4,SM2 那部分要么没算 ZA、要么签名格式和对方对不上。最后落到这份纯 C# 的国密实现上:SM2/SM3/SM4 全部用托管代码写完,不引第三方加密库,一个算法一个目录,自带标准测试向量。
它适合三类人:正在给 .NET 服务端做国密改造的后端,需要在离线环境里做签名验签的工具链开发,以及想看懂国密算法内部结构、而不是只会调一个Sign()的开发者。下面按目录结构、核心代码、跑通、踩坑、二次开发的顺序拆。
2. 源码骨架与技术选型:一个算法一个目录的托管实现
拿到一份算法库,我第一件事不是看算法,是看目录——目录能告诉你作者是怎么划分职责的,也能告诉你哪儿是边界、哪儿能改。
2.1 目录结构:算法、工具、测试三层分离
这份源码的目录组织很朴素,基本是「一个算法一个文件夹,公共代码抽到 Util,测试向量单独放」:
GuoMi/ ├── SM2/ │ ├── SM2.cs # 签名/验签/加解密对外入口 │ ├── SM2KeyPair.cs # 密钥对生成与序列化 │ ├── ECCPoint.cs # 椭圆曲线点、点加、倍点、标量乘 │ └── SM2Curve.cs # 推荐曲线参数(p/a/b/n/Gx/Gy) ├── SM3/ │ ├── SM3.cs # 摘要主流程:填充 + 迭代压缩 │ └── SM3Context.cs # 分组缓冲,支持分段 Update ├── SM4/ │ ├── SM4.cs # 32 轮加解密、密钥扩展 │ ├── SM4SBox.cs # 256 字节 S 盒常量 │ └── SM4Mode.cs # ECB/CBC/CTR 工作模式封装 ├── Util/ │ ├── ByteUtil.cs # 大端小端、Hex、异或、固定长度补齐 │ ├── Padding.cs # PKCS7 / NoPadding / ZeroPadding │ └── RandomUtil.cs # 基于 RandomNumberGenerator 的随机数 └── Tests/ ├── SM3Test.cs # 标准向量:"abc"、512 位消息等 ├── SM4Test.cs # 标准向量:单分组加密结果比对 └── SM2Test.cs # 签名验签、加解密往返这个划分有一个明显好处:SM2/目录里只关心曲线和数论,SM3/只关心字节进、32 字节出,SM4/只关心 16 字节分组。三个算法之间没有交叉引用,SM2 里算 ZA 的时候调用 SM3,是单向依赖,不会绕回去。二次开发时你想把 SM4 换成硬件加密机的实现,只需要在SM4Mode那一层做适配,上层的业务代码不用动。
Tests/目录的存在很关键。国密算法的坑大多不在算法本身,在编码和格式上,而标准测试向量是唯一能快速判定「是我实现错了还是对接方格式不一样」的尺子。我一般拿到这类库,第一件事就是把Tests里的用例全跑一遍,确认基线是通的,再去改业务。
2.2 技术选型:为什么用 BigInteger 手写,而不是包一层
这份实现选的是 .NET Standard 2.0 作为目标框架,核心大数运算用System.Numerics.BigInteger,随机数用System.Security.Cryptography.RandomNumberGenerator。整个库不引用 BouncyCastle,也不 P/Invoke 任何本地库。
| 方案 | 优点 | 代价 |
|---|---|---|
| 纯 C# + BigInteger | 无部署依赖,跨平台一致,源码可读可改 | 椭圆曲线点运算性能一般,需要自己做坐标优化 |
| 包装本地 C 库 | 性能好 | 部署要带 so/dll,容器镜像变大,版本对齐麻烦 |
| 引第三方加密库 | 省事 | 版本升级不可控,出问题只能等上游修 |
在业务侧做国密改造,最怕的不是慢,是「在我机器上能跑,到客户现场跑不起来」。纯托管实现把部署风险降到了最低,代价是 SM2 的标量乘会慢一些。对这个场景来说完全能接受——签名验签是每次请求一次到几次的量级,不是每秒几万次。
BigInteger在这里承担了模幂、模逆、大数比较这些活。它有几个必须记住的特性:ToByteArray()返回的是小端序,而且最高位为 1 时会多补一个0x00符号字节。这两点几乎是所有 C# 国密实现出 bug 的重灾区,后面避坑那章会展开。
2.3 公共底座:字节序与十六进制转换
ByteUtil看着不起眼,但它决定了整个库和外部系统能不能对上。国密标准里所有整数都是大端表示,而BigInteger默认小端,所以这一层的转换必须封死:
public static class ByteUtil { // 大端字节数组 -> BigInteger(正数) public static BigInteger FromBigEndian(byte[] data) { // 反转成小端后再补 0x00,避免最高位被解释成负数 var little = new byte[data.Length + 1]; for (int i = 0; i < data.Length; i++) little[i] = data[data.Length - 1 - i]; little[data.Length] = 0x00; return new BigInteger(little); } // BigInteger -> 固定长度的大端字节数组 public static byte[] ToBigEndian(BigInteger value, int length) { var raw = value.ToByteArray(); // 小端,可能带符号字节 var result = new byte[length]; int start = raw.Length > length ? raw.Length - length : 0; for (int i = start; i < raw.Length; i++) { int idx = length - 1 - (i - start); if (idx < 0) break; result[idx] = raw[i]; } return result; // 高位自动补 0 } public static string ToHex(byte[] data) => BitConverter.ToString(data).Replace("-", "").ToLowerInvariant(); public static byte[] FromHex(string hex) { var bytes = new byte[hex.Length / 2]; for (int i = 0; i < bytes.Length; i++) bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); return bytes; } }FromBigEndian里多补的那个0x00是关键:如果直接从大端字节反转得到小端数组,最高位为 1 时BigInteger会把它当成负数读,签名算出来的r、s会莫名其妙变成负值。ToBigEndian里的length参数是调用方指定的,SM2 的坐标固定 32 字节,签名值固定 32 字节,传错长度会导致前后补零位置错位。
ToHex统一转小写也是有意为之。对接方如果给的是大写十六进制,FromHex里Convert.ToByte本身能兼容大小写,但输出侧统一小写能减少比对时的噪音。
3. SM3 与 SM4:摘要和对称加密的核心链路
SM3 是哈希,SM4 是分组加密,两个算法在实现上共享同一套「分组缓冲 + 迭代」的骨架,但内部逻辑完全不同。这一章把两者的核心代码拆开看。
3.1 SM3 的消息填充与分组迭代
SM3 输出 256 位,分组长度 512 位(64 字节)。填充规则和 SHA-256 类似:先补一个0x80,再补若干0x00直到长度模 64 等于 56,最后 8 字节写入原始消息的比特长度(大端)。
public byte[] ComputeHash(byte[] message) { // 1. 填充:0x80 + 若干个 0x00 + 8 字节比特长度 long bitLen = (long)message.Length * 8; int padLen = (message.Length % 64 < 56) ? 56 - message.Length % 64 : 120 - message.Length % 64; var buffer = new byte[message.Length + padLen + 8]; Array.Copy(message, buffer, message.Length); buffer[message.Length] = 0x80; // 填充首字节 var lenBytes = ByteUtil.ToBigEndian(new BigInteger(bitLen), 8); Array.Copy(lenBytes, 0, buffer, buffer.Length - 8, 8); // 2. 逐 64 字节分组迭代压缩 uint[] v = { 0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600, 0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e }; for (int offset = 0; offset < buffer.Length; offset += 64) Compress(v, buffer, offset); // 3. 8 个 32 位字按大端拼接成 32 字节 var result = new byte[32]; for (int i = 0; i < 8; i++) Array.Copy(ByteUtil.ToBigEndian(v[i], 4), 0, result, i * 4, 4); return result; }填充长度的计算是padLen:如果当前长度模 64 小于 56,补到 56;否则跨过分组补到下一个 56(也就是 120)。这个条件写错是 SM3 最常见的低级错误,表现是「空字符串能算对,超过 55 字节就全乱」。
v是初始向量 IV,8 个 32 位常量,每一组压缩后原地更新,最后一组的结果就是摘要。ToBigEndian(v[i], 4)把每个字转成 4 字节大端,拼起来就是SM3("abc") = 66c7f0f4...那个标准结果。
如果要做分段哈希(比如流式读文件),就不能一次性传整个数组,这时候要用一个上下文对象保存v和未满 64 字节的尾部缓冲,每次Update攒够一组就压一次。这份源码里SM3Context就是干这个的,逻辑不难,但边界处理要小心——最后一次Final必须按当前累计长度算填充,不能用总长度重新来过。
3.2 压缩函数:64 轮里的 W 与 W'
压缩函数是 SM3 的心脏,也是性能热点。消息扩展先生成 68 个W,再由W派生出 64 个W',然后跑 64 轮状态更新:
private static void Compress(uint[] v, byte[] block, int offset) { uint[] w = new uint[68]; for (int i = 0; i < 16; i++) w[i] = ReadUInt32BigEndian(block, offset + i * 4); // 消息扩展:W[16..67] for (int j = 16; j < 68; j++) { uint x = w[j - 16] ^ w[j - 9] ^ Rotl(w[j - 3], 15); w[j] = P1(x) ^ Rotl(w[j - 13], 7) ^ w[j - 6]; } uint a = v[0], b = v[1], c = v[2], d = v[3]; uint e = v[4], f = v[5], g = v[6], h = v[7]; for (int j = 0; j < 64; j++) { uint tj = j < 16 ? 0x79cc4519u : 0x7a879d8au; uint ss1 = Rotl(unchecked(Rotl(a, 12) + e + Rotl(tj, j % 32)), 7); uint ss2 = ss1 ^ Rotl(a, 12); uint tt1 = unchecked(FF(a, b, c, j) + d + ss2 + (w[j] ^ w[j + 4])); uint tt2 = unchecked(GG(e, f, g, j) + h + ss1 + w[j]); d = c; c = Rotl(b, 9); b = a; a = tt1; h = g; g = Rotl(f, 19); f = e; e = P0(tt2); } v[0] ^= a; v[1] ^= b; v[2] ^= c; v[3] ^= d; v[4] ^= e; v[5] ^= f; v[6] ^= g; v[7] ^= h; } private static uint P0(uint x) => x ^ Rotl(x, 9) ^ Rotl(x, 17); private static uint P1(uint x) => x ^ Rotl(x, 15) ^ Rotl(x, 23); private static uint FF(uint x, uint y, uint z, int j) => j < 16 ? x ^ y ^ z : (x & y) | (x & z) | (y & z); private static uint GG(uint x, uint y, uint z, int j) => j < 16 ? x ^ y ^ z : (x & y) | (~x & z); private static uint Rotl(uint x, int n) => (x << n) | (x >> (32 - n));几个参数要盯住:tj在 0~15 轮取0x79cc4519,16~63 轮取0x7a879d8a,并且在进入Rotl之前要对j取模 32,否则Rotl里x >> (32 - n)在n ≥ 32时会触发 C# 的移位计数截断(C# 只取移位量的低 5 位),结果看起来「偶尔对、偶尔错」,非常难查。
所有uint加法都套了unchecked。SM3 的加法本来就是模 2³² 溢出,如果项目里开了<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>,不写unchecked会直接抛OverflowException。这一点在调试环境里尤其容易翻车,因为默认配置下不检查溢出,发布到另一个项目引用时才炸。
3.3 SM4 密钥扩展与 32 轮加解密
SM4 是 128 位分组、128 位密钥、32 轮迭代,加解密结构相同,只是轮密钥顺序相反。先看密钥扩展:
private static readonly uint[] FK = { 0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc }; // 系统参数 CK 由公式生成:ck[4i+j] = (4i+j)*7 mod 256 private static uint[] GenerateCK() { var ck = new uint[32]; for (int i = 0; i < 32; i++) { byte b0 = (byte)((4 * i + 0) * 7 % 256); byte b1 = (byte)((4 * i + 1) * 7 % 256); byte b2 = (byte)((4 * i + 2) * 7 % 256); byte b3 = (byte)((4 * i + 3) * 7 % 256); ck[i] = ((uint)b0 << 24) | ((uint)b1 << 16) | ((uint)b2 << 8) | b3; } return ck; } private static uint[] ExpandKey(byte[] key) { var ck = GenerateCK(); uint[] k = new uint[36]; for (int i = 0; i < 4; i++) k[i] = ReadUInt32BigEndian(key, i * 4) ^ FK[i]; var rk = new uint[32]; for (int i = 0; i < 32; i++) { uint t = k[i + 1] ^ k[i + 2] ^ k[i + 3] ^ ck[i]; k[i + 4] = k[i] ^ LPrime(Tau(t)); rk[i] = k[i + 4]; } return rk; } private static uint LPrime(uint b) => b ^ Rotl(b, 13) ^ Rotl(b, 23);FK是固定参数,CK是公式生成的系统参数——很多人直接把 32 个CK硬编码进代码,能跑,但一旦有人改了其中一位排查起来很痛苦。这里用公式生成,反而更可信,因为(4i+j)*7 mod 256是可以自己验算的。
Tau是 4 个 S 盒并行替换,SBox表在SM4SBox.cs里,256 字节,是标准表。LPrime是密钥扩展专用的线性变换,异或了rotl 13和rotl 23;加解密用的L变换异或的是rotl 2 / 10 / 18 / 24,这两个函数千万别混用,混了的表现是「加密再解密回不到原文」。
加解密主体:
public byte[] Crypt(byte[] input, byte[] key, bool encrypt) { if (input.Length % 16 != 0) throw new ArgumentException("SM4 分组长度必须是 16 的整数倍"); var rk = ExpandKey(key); if (!encrypt) Array.Reverse(rk); // 解密时轮密钥反序 var output = new byte[input.Length]; var x = new uint[36]; for (int offset = 0; offset < input.Length; offset += 16) { for (int i = 0; i < 4; i++) x[i] = ReadUInt32BigEndian(input, offset + i * 4); for (int i = 0; i < 32; i++) { uint t = x[i + 1] ^ x[i + 2] ^ x[i + 3] ^ rk[i]; x[i + 4] = x[i] ^ L(Tau(t)); } // 反序变换:X35, X34, X33, X32 for (int i = 0; i < 4; i++) WriteUInt32BigEndian(output, offset + i * 4, x[35 - i]); } return output; } private static uint L(uint b) => b ^ Rotl(b, 2) ^ Rotl(b, 10) ^ Rotl(b, 18) ^ Rotl(b, 24);注意最后的反序变换:32 轮之后输出顺序是X35, X34, X33, X32,不是X32..X35。写成顺序输出,加密结果和标准向量对不上,而且解密也回不来——这是 SM4 实现里排名第一的错。
Array.Reverse(rk)实现解密是标准做法:SM4 的解密轮密钥就是加密轮密钥倒序,轮函数本身不变,所以不需要写第二套逻辑。这也意味着ExpandKey的结果可以被缓存复用,同一把密钥反复加密时不必每次重算。
3.4 工作模式:ECB/CBC/CTR 与填充策略
裸的 SM4 只能处理 16 字节整数倍的数据,业务报文一般不是这个长度,所以必须有填充和工作模式。这份源码在SM4Mode.cs里封了三种:
| 模式 | 是否需要 IV | 是否可并行 | 典型用途 |
|---|---|---|---|
| ECB | 否 | 是 | 只用于测试向量验证,业务禁用 |
| CBC | 是,16 字节 | 加密串行、解密可并行 | 报文加密、字段加密 |
| CTR | 是,16 字节计数器 | 是 | 大文件流式加密 |
CBC 的实现重点在链式异或和填充:
public byte[] EncryptCbc(byte[] plain, byte[] key, byte[] iv) { // PKCS7 填充:缺 n 字节就补 n 个 n int pad = 16 - plain.Length % 16; var padded = new byte[plain.Length + pad]; Array.Copy(plain, padded, plain.Length); for (int i = plain.Length; i < padded.Length; i++) padded[i] = (byte)pad; var cipher = new byte[padded.Length]; var prev = (byte[])iv.Clone(); for (int offset = 0; offset < padded.Length; offset += 16) { var block = new byte[16]; for (int i = 0; i < 16; i++) block[i] = (byte)(padded[offset + i] ^ prev[i]); // 先与前一密文块异或 var enc = Crypt(block, key, true); Array.Copy(enc, 0, cipher, offset, 16); prev = enc; } return cipher; }pad的计算有一个细节:当明文长度正好是 16 的整数倍时,16 - 0 = 16,会补一整块 16 个0x10。这是 PKCS7 的规定,不是 bug。解密侧必须按最后一个字节的值去掉对应数量的字节,并且要校验这个值在 1~16 之间,否则就是填充预言攻击的入口。
iv必须每次加密随机生成,和密文一起传给对方。写死 IV 等于把 CBC 退化成 ECB,安全性直接掉一个档次。RandomUtil里用RandomNumberGenerator.GetBytes(16)生成,别用Random类——后者不是密码学安全的。
4. SM2 椭圆曲线:密钥、签名、加解密全链路
SM2 是这三个算法里最复杂的一个,它建立在 256 位素数域椭圆曲线上,涉及点运算、模逆、KDF、ZA 计算。这一章按「参数 → 点运算 → 签名 → 加解密」的顺序拆。
4.1 曲线参数与密钥对生成
推荐曲线参数是固定的,写在SM2Curve.cs里:
public static class SM2Curve { public static readonly BigInteger P = ByteUtil.FromBigEndian(ByteUtil.FromHex( "FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF")); public static readonly BigInteger A = ByteUtil.FromBigEndian(ByteUtil.FromHex( "FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC")); public static readonly BigInteger B = ByteUtil.FromBigEndian(ByteUtil.FromHex( "28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93")); public static readonly BigInteger N = ByteUtil.FromBigEndian(ByteUtil.FromHex( "FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123")); public static readonly BigInteger Gx = ByteUtil.FromBigEndian(ByteUtil.FromHex( "32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7")); public static readonly BigInteger Gy = ByteUtil.FromBigEndian(ByteUtil.FromHex( "BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0")); }P是域素数,A、B是曲线方程y² = x³ + ax + b的系数,N是基点的阶,Gx/Gy是基点坐标。这六个值一个都不能错,错一位签名就永远验不过,而且报错信息通常只是「验签失败」,没有更多线索。
密钥对生成就是取一个[1, n-1]的随机数d作为私钥,公钥是P = [d]G:
public static SM2KeyPair GenerateKeyPair() { BigInteger d; do { var buf = RandomUtil.GetBytes(32); d = ByteUtil.FromBigEndian(buf); } while (d <= BigInteger.Zero || d >= SM2Curve.N); var pub = ECCPoint.Multiply(SM2Curve.Gx, SM2Curve.Gy, d); return new SM2KeyPair { PrivateKey = ByteUtil.ToBigEndian(d, 32), PublicKey = Concat( ByteUtil.ToBigEndian(pub.X, 32), ByteUtil.ToBigEndian(pub.Y, 32)) }; }公钥的序列化格式是 64 字节裸X || Y,不带0x04前缀。很多对接方给的是 65 字节的未压缩点格式(04 || X || Y),两边一对比长度对不上,是这个库接入时第一个要确认的点。
do-while的边界检查不能省:d必须严格大于 0 且小于N,概率上几乎不会命中,但一旦命中就是灾难性的弱密钥。
4.2 点运算:仿射够用,雅可比更快
ECCPoint提供点加、倍点和标量乘。仿射坐标下的点加需要算模逆,而模逆是椭圆曲线里最贵的运算,一次标量乘要算 256 次点加加倍点,如果每次都用仿射,性能会很难看。这份源码的做法是:小规模运算用仿射,标量乘走雅可比坐标。
// 仿射坐标点加:斜率 = (y2-y1)/(x2-x1) mod p public static ECCPoint Add(ECCPoint p1, ECCPoint p2) { if (p1.IsInfinity) return p2; if (p2.IsInfinity) return p1; if (p1.X == p2.X) { if ((p1.Y + p2.Y) % SM2Curve.P == 0) return ECCPoint.Infinity; return Double(p1); } var lambda = Mod((p2.Y - p1.Y) * ModInverse(p2.X - p1.X, SM2Curve.P), SM2Curve.P); var x3 = Mod(lambda * lambda - p1.X - p2.X, SM2Curve.P); var y3 = Mod(lambda * (p1.X - x3) - p1.Y, SM2Curve.P); return new ECCPoint(x3, y3); } // 倍点:斜率 = (3x² + a) / 2y mod p public static ECCPoint Double(ECCPoint p) { if (p.IsInfinity || p.Y == 0) return ECCPoint.Infinity; var lambda = Mod((3 * p.X * p.X + SM2Curve.A) * ModInverse(2 * p.Y, SM2Curve.P), SM2Curve.P); var x3 = Mod(lambda * lambda - 2 * p.X, SM2Curve.P); var y3 = Mod(lambda * (p.X - x3) - p.Y, SM2Curve.P); return new ECCPoint(x3, y3); }Add里有三个分支必须走全:两个无穷远点、同 x 同 y(走倍点)、同 x 反 y(结果为无穷远点)。漏掉第三个分支,在标量乘的中间过程里迟早会遇到「点加自己」的情况,然后算出错误结果。
Mod要做负数归一化,BigInteger的%在负数时返回负余数,直接参与后续计算会污染整条链路。常见写法是:
private static BigInteger Mod(BigInteger x, BigInteger m) { var r = x % m; return r.Sign < 0 ? r + m : r; }标量乘用二进制展开,从高位到低位,遇 1 则加,每步倍点:
public static ECCPoint Multiply(BigInteger k, ECCPoint point) { var result = ECCPoint.Infinity; var addend = point; while (k > 0) { if ((k & 1) == 1) result = Add(result, addend); addend = Double(addend); k >>= 1; } return result; }这个写法是「从低位到高位」,每轮都倍点、遇 1 就加,正确性没问题。真正的性能优化在实现里用的是雅可比坐标,避免中间过程反复求逆,只在整个标量乘结束时做一次模逆回仿射。二次开发时如果想提性能,优先改这一块,改完签名耗时通常能降一个数量级。
4.3 签名与验签:ZA 是绕不开的一步
SM2 签名的输入不是裸消息,而是e = SM3(ZA || M),其中ZA是用户身份的可哈希摘要。这一步是绝大多数「签名结果和对方对不上」的根因——对方用标准工具签的,你这边直接SM3(M),必然不一致。
// ZA = SM3(ENTL || IDA || a || b || Gx || Gy || Px || Py) private static byte[] ComputeZA(byte[] userId, byte[] pubX, byte[] pubY) { var entl = ByteUtil.ToBigEndian(new BigInteger(userId.Length * 8), 2); var input = Concat( entl, userId, ByteUtil.ToBigEndian(SM2Curve.A, 32), ByteUtil.ToBigEndian(SM2Curve.B, 32), ByteUtil.ToBigEndian(SM2Curve.Gx, 32), ByteUtil.ToBigEndian(SM2Curve.Gy, 32), pubX, pubY); return new SM3().ComputeHash(input); } public SM2Signature Sign(byte[] message, byte[] privateKey, byte[] userId) { var d = ByteUtil.FromBigEndian(privateKey); var pub = ECCPoint.Multiply(SM2Curve.Gx, SM2Curve.Gy, d); var za = ComputeZA(userId, ByteUtil.ToBigEndian(pub.X, 32), ByteUtil.ToBigEndian(pub.Y, 32)); var e = ByteUtil.FromBigEndian( new SM3().ComputeHash(Concat(za, message))); BigInteger r, s; do { var k = RandomInRange(BigInteger.One, SM2Curve.N - 1); var p1 = ECCPoint.Multiply(k, new ECCPoint(SM2Curve.Gx, SM2Curve.Gy)); r = Mod(e + p1.X, SM2Curve.N); if (r == 0 || r + k == SM2Curve.N) continue; s = Mod(ModInverse(1 + d, SM2Curve.N) * (k - r * d), SM2Curve.N); if (s == 0) continue; break; } while (true); return new SM2Signature { R = ByteUtil.ToBigEndian(r, 32), S = ByteUtil.ToBigEndian(s, 32) }; }ENTL是用户 ID 的比特长度,占 2 字节;默认 ID 是"1234567812345678"(16 字节,ENTL = 0x0080)。对接方如果指定了别的 ID,必须原样传进来,不能偷懒用默认值。
r + k == n这个重试条件容易被忽略。它是一个概率极低的边界情况,但标准里明确要求重来,不处理的话签出来的值在极少数情况下验签失败,属于那种「测一百次都没事,上线三个月出一次」的问题。
验签的逻辑是签名的逆过程:
public bool Verify(byte[] message, SM2Signature sig, byte[] publicKey, byte[] userId) { var r = ByteUtil.FromBigEndian(sig.R); var s = ByteUtil.FromBigEndian(sig.S); if (r < 1 || r >= SM2Curve.N || s < 1 || s >= SM2Curve.N) return false; var px = ByteUtil.FromBigEndian(publicKey[..32]); var py = ByteUtil.FromBigEndian(publicKey[32..]); var za = ComputeZA(userId, ByteUtil.ToBigEndian(px, 32), ByteUtil.ToBigEndian(py, 32)); var e = ByteUtil.FromBigEndian(new SM3().ComputeHash(Concat(za, message))); var t = Mod(r + s, SM2Curve.N); if (t == 0) return false; var p = ECCPoint.Add( ECCPoint.Multiply(s, new ECCPoint(SM2Curve.Gx, SM2Curve.Gy)), ECCPoint.Multiply(t, new ECCPoint(px, py))); if (p.IsInfinity) return false; return Mod(e + p.X, SM2Curve.N) == r; }publicKey[..32]这种切片写法需要 C# 8 以上,如果目标框架是 .NET Standard 2.0,要改成Array.Copy或者用Span版本。这是移植时容易漏掉的语法兼容问题。
验签里t == 0和p.IsInfinity这两个判断都是标准要求的合法性检查,不是可选的防御性代码。少了它们,某些构造出来的非法签名会通过验证。
4.4 公钥加解密:C1C2C3 与 KDF
SM2 的公钥加密输出三段:C1是临时公钥点(64 字节),C2是密文(与明文等长),C3是摘要(32 字节)。这三段的顺序在标准演进中改过,现在主流是C1C3C2,但老系统里C1C2C3依然大量存在,这是对接时第二个必须确认的点。
public byte[] Encrypt(byte[] plain, byte[] publicKey) { var px = ByteUtil.FromBigEndian(publicKey[..32]); var py = ByteUtil.FromBigEndian(publicKey[32..]); var pb = new ECCPoint(px, py); while (true) { var k = RandomInRange(BigInteger.One, SM2Curve.N - 1); var c1 = ECCPoint.Multiply(k, new ECCPoint(SM2Curve.Gx, SM2Curve.Gy)); var s = ECCPoint.Multiply(k, pb); // (x2, y2) if (s.IsInfinity) continue; var x2 = ByteUtil.ToBigEndian(s.X, 32); var y2 = ByteUtil.ToBigEndian(s.Y, 32); var t = Kdf(Concat(x2, y2), plain.Length); if (IsAllZero(t)) continue; // KDF 全零要换 k 重来 var c2 = new byte[plain.Length]; for (int i = 0; i < plain.Length; i++) c2[i] = (byte)(plain[i] ^ t[i]); var c3 = new SM3().ComputeHash(Concat(x2, plain, y2)); return Concat(ByteUtil.ToBigEndian(c1.X, 32), ByteUtil.ToBigEndian(c1.Y, 32), c3, c2); // C1C3C2 } } // KDF:以 SM3 为基,ct 从 1 开始递增拼接,取前 klen 字节 private static byte[] Kdf(byte[] z, int klen) { var output = new byte[klen]; int offset = 0; uint ct = 1; while (offset < klen) { var input = Concat(z, ByteUtil.ToBigEndian(new BigInteger(ct), 4)); var digest = new SM3().ComputeHash(input); int copy = Math.Min(32, klen - offset); Array.Copy(digest, 0, output, offset, copy); offset += copy; ct++; } return output; }Kdf的计数器ct是 32 位大端,从 1 开始。写成从 0 开始或者小端,解出来的明文就是乱码,而且加密解密都在自己库里跑的时候不会暴露——只有和外部系统对接才现原形,所以测试一定要用对方的密文来解。
IsAllZero(t)这个检查是标准要求的:如果生成的密钥流全为 0,说明这个k不安全,要重新选。概率极低,但实现里加上不亏。
解密侧要做的检查更多:C1是否在曲线上、S = [h]C1是否为无穷远点、C3是否匹配。最后一条是关键,C3对不上说明密文被篡改或者密钥不对,必须抛异常而不是返回错误的明文:
var u = new SM3().ComputeHash(Concat(x2, plain, y2)); if (!u.SequenceEqual(c3)) throw new CryptographicException("SM2 密文完整性校验失败");SequenceEqual逐字节比较,用==比引用会永远返回 false,这个坑在小项目里我见过不止一次。
5. 避坑与常见问题排查:编码、格式、填充这三类翻车现场
这套库跑通标准向量只是起点,真正花时间的是和外部系统对齐。下面四条是我在实际对接里踩过、或者帮人排查过的。
5.1 现象:本地算的摘要和对方不一致,中文尤其明显
原因:待签字符串转字节时用了Encoding.Default。在 .NET Framework 下它跟随系统区域设置,中文 Windows 上是 GBK;到了 .NET Core / Linux 上默认变成 UTF-8。同一份代码在不同机器上算出的 SM3 值不同,且只在含中文时暴露。
解决:所有参与摘要和签名的字符串,统一显式指定Encoding.UTF8,并在对接文档里写死。更稳的做法是接口层只传字节数组,字符串到字节的转换在调用方完成,库本身不碰编码:
// 错误:跟随平台默认编码 var data = Encoding.Default.GetBytes(text); // 正确:显式 UTF-8,并在文档里约定 var data = Encoding.UTF8.GetBytes(text);排查时可以先算SM3("abc")的标准值66c7f0f4...,如果这个都对不上,问题在实现;如果这个对得上、业务字符串对不上,九成是编码。
5.2 现象:签名值长度是 70 或 71 字节,对方要求 64 字节
原因:SM2 签名结果有两种常见封装,一种是裸的r || s各 32 字节共 64 字节,另一种是 ASN.1 DER 编码的SEQUENCE { INTEGER r, INTEGER s },长度随 r、s 的最高位变化,通常在 70~72 字节之间。很多通用加密库默认输出 DER,而这个库输出裸值,长度一对不上就报「签名格式错误」。
解决:先确认对方要哪种。要 DER 就自己编一个最小编码器,注意 INTEGER 首字节最高位为 1 时要补0x00防止被解释为负数:
// 裸 r||s -> DER:SEQUENCE( INTEGER r, INTEGER s ) private static byte[] ToDer(byte[] r, byte[] s) { var ri = TrimLeadingZeros(r); var si = TrimLeadingZeros(s); if ((ri[0] & 0x80) != 0) ri = Concat(new byte[] { 0x00 }, ri); if ((si[0] & 0x80) != 0) si = Concat(new byte[] { 0x00 }, si); var body = Concat(new byte[] { 0x02, (byte)ri.Length }, ri, new byte[] { 0x02, (byte)si.Length }, si); return Concat(new byte[] { 0x30, (byte)body.Length }, body); }同理,SM2 密文的C1C2C3和C1C3C2顺序也要确认。判断方法很简单:密文长度减去 96 字节(C1 64 + C3 32)就是明文长度,然后看第三段是不是 32 字节——是C3就说明是C1C3C2。
5.3 现象:SM4 解密报填充错误,或者解出来尾部多几个字节
原因:填充方式不一致。一方用 PKCS7,另一方用 NoPadding 自己补零;或者一方在加密前做了 Base64,另一方直接对 Base64 字符串的字节做解密。
解决:把「分组模式 + 填充方式 + 编码」当成一个三元组写进对接约定,任何一项不确定都不要开始联调。排查时可以先用一个 16 字节整倍数、内容为全零的明文做一次 ECB 加密,和标准向量比对,确认算法本身没问题,再去查填充。
// 联调期临时打印,确认分组和填充 Console.WriteLine($"明文长度: {plain.Length}, 密文长度: {cipher.Length}"); Console.WriteLine($"末字节: 0x{cipher[^1]:X2}"); // PKCS7 末字节即填充长度PKCS7 的密文末字节解密后应该等于填充长度;如果末字节是个随机值,说明对方用的不是 PKCS7。
5.4 现象:并发调用时偶发验签失败,重启后又正常
原因:把SM3或SM4的实例注册成单例,而内部用了可变的缓冲区字段(v数组、分组缓冲)。多个线程同时Update会互相踩数据,表现为偶发、不可复现、日志里看不出规律。
解决:算法实例要么每次 new,要么改成无状态的静态方法,所有中间状态放局部变量。这份源码的SM3.ComputeHash就是无状态的,每次调用新建局部数组;需要分段哈希时才用SM3Context,并且明确它不线程安全。真正要缓存的是只读的常量表,比如 S 盒、CK、FK,这些用static readonly没问题:
// 只读常量可以共享 private static readonly uint[] CK = GenerateCK(); // 可变状态绝不能共享,放在方法内的局部变量 var v = new uint[8] { 0x7380166f, ... };如果确实要在高并发下复用实例,用ThreadLocal<T>或者对象池,但前提是把状态彻底隔离干净,别只隔离一半。
6. 二次开发:把国密库接进业务系统的改造思路
这套源码直接拿来当工具类用没问题,但接进业务系统,我一般会再做三件事。
第一件是抽接口。不要让业务代码直接new SM2(),而是定义ISignatureService、ISymmetricCipher这类接口,实现类内部包国密库。这样以后对方要求换成硬件加密机、或者某天要加国际算法做双签,只换实现类,业务层不动:
public interface ISignatureService { byte[] Sign(byte[] data, string keyId); bool Verify(byte[] data, byte[] signature, string keyId); }第二件是把密钥管理挪出代码。私钥不能硬编码在配置文件里,也不能每次从数据库明文读。常见做法是启动时从密钥管理服务拉一次,放内存里的受保护结构,用完不清零但限制访问路径。至少要做到私钥不出服务进程,签名接口只暴露keyId而不是私钥本身。
第三件是补测试。标准向量只能证明算法对,证明不了业务对。我会额外加三组用例:一组是「用对方给的密文能解出预期明文」,一组是「篡改密文任意一个字节必须抛异常」,一组是「签名后再验签 1000 次全部通过」。第三组能抓出随机数问题,第二组能抓出C3校验被跳过的问题。
性能上如果签名成了瓶颈,优先改ECCPoint.Multiply,把仿射坐标换成雅可比坐标,只在整个标量乘结束时做一次模逆。这一步改完通常能快 5~10 倍,比换语言、换库都划算:
| 优化点 | 改动量 | 预期收益 |
|---|---|---|
| 雅可比坐标标量乘 | 中 | 签名耗时降一个数量级 |
| 预计算基点倍点表 | 中 | 固定基点的乘更快 |
SM4 用Span<byte>减少拷贝 | 小 | 大报文加密吞吐提升 |
| 常量表缓存复用 | 小 | 减少 GC 压力 |
最后说个教训。早期我图省事,把 SM2 的验签逻辑直接抄进业务方法里,ZA计算那段顺手用了默认用户 ID,测试环境对方没校验 ID,一切正常;上线后对方换了 ID,所有签名全部验不过,日志里只有一句「验签失败」,查了整整一个下午才发现是那 16 个字节的问题。从那以后我每次接国密,都先拿标准向量把三个算法各跑一遍,再把ZA、密文顺序、填充方式这三个约定写成配置项显式传进去,不再依赖任何「默认值」。希望帮到你。
本文还有配套的精品资源,点击获取