ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#国密算法库拆解:SM2/SM3/SM4实现与联调避坑

C#国密算法库拆解:SM2/SM3/SM4实现与联调避坑

简介:面向需要在 C#/.NET 项目中落地国密算法的开发者与学习者,这套代码示例围绕 SM2、SM3、SM4 三种算法给出可运行的 WinForm 演示,帮助理解密钥生成、签名验签、摘要计算及分组加解密的调用方式。压缩包共 51 个文件,约 5.33MB,主体为 cs 源码、resx 界面资源、dll 依赖库与 exe 可执行程序,并附带解决方案与项目配置,便于在 Visual Studio 中打开、调试和二次改造。内容涵盖算法工具类、窗体交互逻辑及 BouncyCastle 加密库引用,读者可对照界面操作观察各算法的输入输出与参数配置,快速梳理国密功能从界面到核心实现的代码路径。已有 1316 人学习下载,适合作为 C# 国密入门、课程实验或业务模块预研的参考样例。

1. 一次验签失败的联调:这套 C# 国密算法库到底补的是哪个洞

对接一个内部系统的签名接口,对方说只收国密算法,SM2 签名加 SM3 摘要,报文用 SM4 加密。很多人第一反应是在项目里搜一下有没有现成的算法包,结果发现可用的 C# 国密算法实现要么只覆盖其中一两种,要么把密钥编码、密文排列这些细节隐藏了起来,等到对方验签失败,就无从查起。这套包含 SM2、SM3、SM4 的 C# 实现,价值在于把曲线参数、补位规则、ZA 值计算、C1C3C2 排列这些容易被第三方库隐藏起来的部分全部展开成可读代码。适合正在做国密改造的后端开发,也适合想搞清楚算法内部机理、准备自己动手增加支持的工程师。

2. 目录与运行:SM2/SM3/SM4 三套算法在工程里怎么分家

2.1 三层拆分:算法内核、编码适配、调用入口

这类 C# 国密库的目录结构通常分成三层,这个划分方式虽然简单,但基本不会出错。算法内核只关心数学和字节操作,不接触任何 IO;编码层负责十六进制、Base64、ASN.1/DER、PEM 之间的转换;调用入口负责把参数整理好丢给内核。第一次阅读源码时按这个顺序看,比从 Program.cs 往下追要快得多。

SmCrypto/ ├── src/ │ ├── SmCrypto.Core/ # 纯算法,无外部依赖 │ │ ├── Sm2/ │ │ │ ├── Sm2Curve.cs # 曲线参数、点运算、点乘 │ │ │ ├── Sm2.cs # 签名验签、加解密、KDF │ │ │ └── Sm2KeyPair.cs # 密钥生成与公私钥解析 │ │ ├── Sm3/ │ │ │ └── Sm3.cs # 常量、填充、压缩函数 │ │ ├── Sm4/ │ │ │ ├── Sm4.cs # 轮函数、单分组加解密 │ │ │ ├── Sm4Constants.cs # S 盒、FK、CK │ │ │ └── Sm4Padding.cs # PKCS7 / 无填充 │ │ ├── Encoding/ │ │ │ ├── Hex.cs # 大小写、带不带空格 │ │ │ └── Asn1.cs # DER 序列化,给密钥和签名用 │ │ └── Utils/ │ │ ├── BigIntegerExtensions.cs# 定长大端转换、模逆 │ │ └── SecureRandom.cs # 密码学安全随机数 │ └── SmCrypto.Console/ │ └── Program.cs # 手敲的验证入口 └── tests/ └── SmCrypto.Tests/ # 标准测试向量 + 自身往返测试

看这个结构能发现一个细节:SM2 单独占一个目录,还分了三个文件。原因很实在,SM2 是椭圆曲线算法,点运算、曲线参数、业务层的签名验签是三件不同的事,混在一个类里,到后面加密钥交换或者换曲线就麻烦了。SM3 和 SM4 各自只有一个主文件,是因为它们的实现相对独立,没有那么多分支。

2.2 目标框架与依赖:为什么宁可手写也不用系统接口

.NET 生态里做国密有个现实问题:系统自带的加密接口在多数版本上没有覆盖 SM2、SM3、SM4,即使新版本在部分平台上补了 SM3/SM4,Windows 和 Linux 的行为也不完全一致,跨平台部署很容易遇到问题。所以这套实现选择纯托管代码,零外部依赖,System.Numerics.BigInteger就是唯一的大数工具。

目标框架怎么选,看调用方。要被老项目引用,就多目标输出netstandard2.0加net8.0;只做新服务,直接上net8.0省事。真正影响代码写法的是这三处:

关注点netstandard2.0net8.0
大数System.Numerics.BigInteger同上,另有TryWriteBytes定长输出
高性能切片需要System.Memory包原生Span<byte>
随机数RNGCryptoServiceProviderRandomNumberGenerator.Fill

我一般会统一写RandomNumberGenerator.Create()拿一个实例,两个框架都能编译,不用写#if。这一点看似无关紧要,但真正到适配各个框架版本时,这一点会节省很多时间。

提示:不要把System.Random用在任何密钥、k 值、IV 上。它产生的序列可预测,SM2 的 k 一旦可预测,私钥就等价于公开了。

2.3 第一次运行:先把 SM3 的哈希值打印出来

SM2 的调试成本高,因为它牵扯随机数、点乘、编码三件事;所以第一次运行不要碰 SM2,先验证 SM3。新建一个控制台项目,引用 Core,写十行代码:

using SmCrypto.Core.Sm3; // 标准附录里的经典向量:SM3("abc") byte[] digest = Sm3.Hash(System.Text.Encoding.ASCII.GetBytes("abc")); System.Console.WriteLine(Hex.ToHex(digest, upper: false));

跑起来:

dotnet run --project src/SmCrypto.Console

正常输出应该是:

66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0

这 64 个十六进制字符对上了,说明填充逻辑、字节序、压缩函数这三块没问题。再补一个长度不是 3 的用例验证填充边界,把"abcd"重复 16 次(正好 64 字节,会触发多补一整个分组),期望结果:

debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732

这两个值都对,SM3 这块基本可以放心了。SM4 用标准向量key = 0123456789abcdeffedcba9876543210、明文与 key 相同,单分组加密结果是681edf34d206965e86b3e94f536e4246,同一个块再加密一次得到595298c7c6fd271f0402f804c33d3f66,加解密往返能对回去,才能说明轮密钥没有写反。SM2 不适合这么验证,后面第 4 章讲怎么用往返测试加对方的公钥做交叉验证。

3. SM3 与 SM4:从填充到轮函数,两段能直接抄的 C# 实现

3.1 SM3 的填充与压缩:64 轮里真正在变的是什么

SM3 输出 256 位,分组 512 位,结构上和常见的哈希算法相近,但消息扩展和轮函数里的置换是自己的一套。先看填充,这是新手最容易写错的地方:

private static byte[] Pad(byte[] msg) { long bitLen = (long)msg.Length * 8; // 比特长度,用 long 防止大报文溢出 int padLen = 64 - (int)((msg.Length + 9) % 64); // 0x80 一字节 + 8 字节长度 if (padLen == 64) padLen = 0; // 刚好对齐时不再补整块 byte[] buf = new byte[msg.Length + 1 + padLen + 8]; Buffer.BlockCopy(msg, 0, buf, 0, msg.Length); buf[msg.Length] = 0x80; // 先补一个 1,后面全 0 for (int i = 0; i < 8; i++) buf[buf.Length - 1 - i] = (byte)(bitLen >> (8 * i)); // 大端写长度 return buf; }

bitLen必须用 64 位整型,长度字段是大端序,这两点在跨语言对接时最常出错。padLen那个if判断也不能少,当(msg.Length + 9) % 64 == 0时会算出 64,如果不处理就会多补一整块,哈希值直接不同。

压缩函数的核心是消息扩展和 64 轮迭代:

private static void Compress(uint[] v, byte[] block, int offset) { uint[] w = new uint[68]; uint[] w1 = new uint[64]; for (int i = 0; i < 16; i++) // 前 16 个字直接读大端 w[i] = ReadBe32(block, offset + i * 4); for (int j = 16; j < 68; j++) // 扩展到 68 个字 w[j] = P1(w[j - 16] ^ w[j - 9] ^ Rol(w[j - 3], 15)) ^ Rol(w[j - 13], 7) ^ w[j - 6]; for (int j = 0; j < 64; j++) w1[j] = w[j] ^ w[j + 4]; // W' 只是 W 的异或组合 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 t = j < 16 ? 0x79cc4519u : 0x7a879d8au; // 前 16 轮与后 48 轮常量不同 uint a12 = Rol(a, 12); uint ss1 = Rol(a12 + e + Rol(t, j % 32), 7); uint ss2 = ss1 ^ a12; uint ff = j < 16 ? (a ^ b ^ c) : ((a & b) | (a & c) | (b & c)); uint gg = j < 16 ? (e ^ f ^ g) : ((e & f) | (~e & g)); uint tt1 = ff + d + ss2 + w1[j]; uint tt2 = gg + h + ss1 + w[j]; d = c; c = Rol(b, 9); b = a; a = tt1; h = g; g = Rol(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 ^ Rol(x, 9) ^ Rol(x, 17); private static uint P1(uint x) => x ^ Rol(x, 15) ^ Rol(x, 23);

这里的参数说明一下:v是 8 个 32 位的初始向量,第一块用固定常量,后续块用上一块的输出;Rol(x, n)是 32 位循环左移,n要先% 32,否则 C# 的移位运算符只取低 5 位,Rol(t, j % 32)里的取模不能省。w数组 68 个字、w1数组 64 个字,这两个长度是固定的,用List或者动态扩容会让每块多做几次分配,压测时差别能看出来。

3.2 SM4 轮函数:一个 T 变换贯穿 32 轮

SM4 是分组长度 128 位、密钥长度 128 位的分组密码,加密和解密用同一套轮函数,只是轮密钥顺序反过来。它的全部复杂度都集中在T变换上:

// 字节代换:把 32 位字按字节逐个查 S 盒 private static uint Tau(uint a) => ((uint)SBox[(a >> 24) & 0xFF] << 24) | ((uint)SBox[(a >> 16) & 0xFF] << 16) | ((uint)SBox[(a >> 8) & 0xFF] << 8) | SBox[a & 0xFF]; // 线性变换,加密用 L,密钥扩展用 L1(少移位一次) private static uint L(uint b) => b ^ Rol(b, 2) ^ Rol(b, 10) ^ Rol(b, 18) ^ Rol(b, 24); private static uint L1(uint b) => b ^ Rol(b, 13) ^ Rol(b, 23); private static uint T(uint x) => L(Tau(x)); // 轮函数里的合成置换 private static uint T1(uint x) => L1(Tau(x)); // 密钥扩展里的合成置换

S 盒是 256 字节的常量表,放在Sm4Constants.cs里。写的时候容易犯的错是手抄表格抄错一个字节,结果只有某些特殊输入才会出错,极难排查。建议直接从标准文档复制,然后写一个单元测试,断言SBox[0] == 0xd6、SBox[255] == 0x48,用首尾两个值做初步校验。

密钥扩展和单分组加解密:

public static uint[] ExpandKey(byte[] key) { if (key.Length != 16) throw new ArgumentException("SM4 密钥必须 16 字节"); uint k0 = ReadBe32(key, 0) ^ 0xa3b1bac6; // FK 是四个固定常量 uint k1 = ReadBe32(key, 4) ^ 0x56aa3350; uint k2 = ReadBe32(key, 8) ^ 0x677d9197; uint k3 = ReadBe32(key, 12) ^ 0xb27022dc; uint[] rk = new uint[32]; for (int i = 0; i < 32; i++) { uint k4 = k0 ^ T1(k1 ^ k2 ^ k3 ^ Ck(i)); // CK 由公式生成,不必手写表 rk[i] = k4; k0 = k1; k1 = k2; k2 = k3; k3 = k4; } return rk; } // CK[i] = (ck0,ck1,ck2,ck3),其中 ck_j = ((4i + j) * 7) mod 256 private static uint Ck(int i) { uint v = 0; for (int j = 0; j < 4; j++) v = (v << 8) | (uint)(((4 * i + j) * 7) & 0xFF); return v; } // 加解密共用:解密时把轮密钥倒着传进来即可 public static void CryptBlock(uint[] rk, byte[] input, int inOff, byte[] output, int outOff) { uint x0 = ReadBe32(input, inOff); uint x1 = ReadBe32(input, inOff + 4); uint x2 = ReadBe32(input, inOff + 8); uint x3 = ReadBe32(input, inOff + 12); for (int i = 0; i < 32; i++) { uint x4 = x0 ^ T(x1 ^ x2 ^ x3 ^ rk[i]); // 一轮只产出一个新字 x0 = x1; x1 = x2; x2 = x3; x3 = x4; } // 输出要反序,这一步漏了会得到完全对不上的结果 WriteBe32(output, outOff, x3); WriteBe32(output, outOff + 4, x2); WriteBe32(output, outOff + 8, x1); WriteBe32(output, outOff + 12, x0); }

参数上要注意两件事。第一,rk是 32 个 32 位轮密钥,加密时正序,解密时rk.Reverse()即可,不需要另写一个解密函数。第二,最后输出必须反序x3 x2 x1 x0,这是标准里写死的反变换,很多人在这卡一下午,因为加密结果看着“像那么回事”,只是每个块都对不上。

3.3 分组模式与填充:ECB 之外还得有 CBC 和 PKCS7

单分组函数只是零件,实际报文长度很少正好是 16 的倍数,所以要有模式和填充。ECB 实现最简单,但同样的明文块会得到同样的密文块,图片、表格类数据一眼就能看出结构,业务上一般不允许用。CBC 更常见:

public static byte[] EncryptCbc(byte[] key, byte[] iv, byte[] plain) { if (iv.Length != 16) throw new ArgumentException("CBC 的 IV 必须 16 字节"); byte[] padded = Sm4Padding.Pkcs7(plain, 16); // 补到 16 的倍数 byte[] cipher = new byte[padded.Length]; byte[] prev = (byte[])iv.Clone(); uint[] rk = ExpandKey(key); byte[] block = new byte[16]; for (int i = 0; i < padded.Length; i += 16) { for (int j = 0; j < 16; j++) block[j] = (byte)(padded[i + j] ^ prev[j]); // 先和上一块密文异或 CryptBlock(rk, block, 0, cipher, i); Buffer.BlockCopy(cipher, i, prev, 0, 16); // 本块密文成为下一块的 prev } return cipher; }

Sm4Padding.Pkcs7的逻辑是:缺 n 字节就补 n 个值为 n 的字节;正好对齐时补一整块 16 个0x10。这个“正好对齐也要补”的规则是解密时判断填充长度的依据,少补这一块,对方解出来的尾巴会被当成数据。IV 每次加密都要换新的随机值,跟着密文一起发过去,不需要保密,但绝不能固定不变,否则相同明文前缀的密文前缀也相同。

注意:CBC 只保证机密性,不保证完整性。要防篡改,还得在密文后面附加 SM3 或 SM2 的校验值,或者干脆用 SM2 加密会话密钥、SM4 加密数据的两段式方案。

4. SM2 关键链路:曲线参数、ZA 值与密文排列

4.1 曲线参数与密钥对:先把 BigInteger 的字节序捋顺

SM2 用的是 256 位素域上的椭圆曲线,曲线方程、基点、阶都是固定常量,直接照标准抄进Sm2Curve.cs。真正的坑不在参数,在BigInteger的字节序:C# 的BigInteger内部是小端,构造函数默认还带符号位,而国密的所有数据都是大端无符号定长。

// 定长 32 字节大端输出,左侧补 0 补齐 internal static byte[] ToFixed32(BigInteger v) { byte[] raw = v.ToByteArray(isUnsigned: true, isBigEndian: true); if (raw.Length == 32) return raw; byte[] result = new byte[32]; Buffer.BlockCopy(raw, 0, result, 32 - raw.Length, raw.Length); return result; } // 反过来读的时候也要声明无符号大端,否则最高位为 1 会变成负数 internal static BigInteger FromFixed32(byte[] buf, int offset) => new BigInteger(new ReadOnlySpan<byte>(buf, offset, 32), isUnsigned: true, isBigEndian: true);

ToFixed32里那个补零动作不能省。椭圆曲线坐标偶尔会出现高位为 0 的情况(概率大约 1/256),如果直接ToByteArray输出,会少一个字节,长度变成 31,对方解析直接报错。这种概率性错误最难排查,本地跑一百次都好,上线跑一万次报一次。

密钥对生成就是取一个[1, n-1]的随机数当私钥,再算d * G得到公钥点:

public static (BigInteger d, byte[] publicKey) GenerateKeyPair() { BigInteger d; do { d = Sm2Curve.RandomScalar(); } while (d.IsZero || d >= Sm2Curve.N); var (x, y) = Sm2Curve.MultiplyG(d); // 未压缩点格式:04 || X(32) || Y(32),共 65 字节 byte[] pub = new byte[65]; pub[0] = 0x04; Buffer.BlockCopy(ToFixed32(x), 0, pub, 1, 32); Buffer.BlockCopy(ToFixed32(y), 0, pub, 33, 32); return (d, pub); }

公钥点的序列化格式有三种:未压缩04||X||Y65 字节、压缩02/03||X33 字节、纯裸X||Y64 字节。不同系统默认值不一样,联调前先问清对方用哪种,或者干脆在库里三种都支持,读的时候看首字节判断。

4.2 签名与验签:ZA 值没算对,后面全白搭

SM2 签名和很多人熟悉的椭圆曲线签名差别在于,被签的不是消息的哈希,而是ZA || M的哈希。ZA是用户标识、曲线参数、公钥点的哈希摘要,作用是把签名和具体身份绑定起来。这一步漏了,或者用户 ID 用了默认值而对方用的是自己的,验签 100% 失败,而且失败信息没有任何提示。

// ZA = SM3(ENTL || ID || a || b || Gx || Gy || Px || Py) private static byte[] ComputeZa(byte[] userId, BigInteger px, BigInteger py) { int bitLen = userId.Length * 8; // ENTL 是 ID 的比特长度,2 字节大端 var ms = new MemoryStream(); ms.WriteByte((byte)(bitLen >> 8)); ms.WriteByte((byte)bitLen); ms.Write(userId); ms.Write(ToFixed32(Sm2Curve.A)); ms.Write(ToFixed32(Sm2Curve.B)); ms.Write(ToFixed32(Sm2Curve.Gx)); ms.Write(ToFixed32(Sm2Curve.Gy)); ms.Write(ToFixed32(px)); ms.Write(ToFixed32(py)); return Sm3.Hash(ms.ToArray()); } public static (BigInteger r, BigInteger s) Sign(byte[] message, BigInteger d, byte[] userId) { var (px, py) = Sm2Curve.MultiplyG(d); byte[] za = ComputeZa(userId, px, py); // e = SM3(ZA || M),再当作 256 位大端无符号整数 byte[] digest = Sm3.Hash(Concat(za, message)); BigInteger e = new BigInteger(digest, isUnsigned: true, isBigEndian: true); while (true) { BigInteger k = Sm2Curve.RandomScalar(); var (x1, _) = Sm2Curve.MultiplyG(k); BigInteger r = (e + x1) % Sm2Curve.N; if (r.IsZero || (r + k) % Sm2Curve.N == 0) continue; // 规范要求重取 k BigInteger s = BigInteger.ModPow(1 + d, Sm2Curve.N - 2, Sm2Curve.N) * (k - r * d) % Sm2Curve.N; if (s.Sign < 0) s += Sm2Curve.N; // C# 取模会保留负号,必须手动修正 if (s.IsZero) continue; return (r, s); } }

逐段说明。ComputeZa里的所有长度都必须定长 32 字节,少一位整体就变了,所以统一走ToFixed32。默认用户 ID 是 16 字节的固定字符串,很多系统的开放接口就是用它,但私有协议经常换成自己的 appId,这一项必须和对方确认。ModPow(x, n-2, n)是在求模逆,因为n是素数,费马小定理成立,比手写扩展欧几里得快,也不用担心边界。

s的负数处理是 C# 特有的一处。C# 的%运算符对负数返回负值,s.Sign < 0时加一次n才能回到[0, n-1]。在 Java 或 Python 里写习惯的人换到 C# 上很容易忽略这一点,结果只有极少部分签名是负数,测试样本少的时候根本发现不了。

验签就是反过来算:检查r、s是否在[1, n-1],算t = (r + s) mod n,t == 0直接拒,然后算点(x1', y1') = s*G + t*P,判断(e + x1') mod n == r。

4.3 加解密与密文顺序:C1C3C2 和 C1C2C3 的互相伤害

SM2 加密产出的三部分:C1是随机点kG,C2是明文异或 KDF 流,C3是校验摘要。老版本标准把顺序写成C1C2C3,新标准改成C1C3C2,两套在现实系统里都还在用,所以库里一定要留一个开关。

public static byte[] Encrypt(byte[] message, byte[] publicKey, bool c1c3c2 = true) { var (x, y) = Sm2Curve.DecodePoint(publicKey); // 04 || X || Y while (true) { BigInteger k = Sm2Curve.RandomScalar(); var (x1, y1) = Sm2Curve.MultiplyG(k); var (x2, y2) = Sm2Curve.Multiply(new Point(x, y), k); byte[] t = Kdf(Concat(ToFixed32(x2), ToFixed32(y2)), message.Length); if (t.All(b => b == 0)) continue; // 全 0 说明 KDF 失效,规范要求重来 byte[] c1 = Sm2Curve.EncodePoint(x1, y1); // 65 字节 byte[] c2 = Xor(message, t); byte[] c3 = Sm3.Hash(Concat(ToFixed32(x2), message, ToFixed32(y2))); return c1c3c2 ? Concat(c1, c3, c2) : Concat(c1, c2, c3); } }

KDF 是基于 SM3 的计数器派生:

internal static byte[] Kdf(byte[] z, int klen) { byte[] output = new byte[klen]; for (int i = 0, ct = 1; i < klen; i += 32, ct++) { // 计数器是 4 字节大端,接在共享点后面一起哈希 byte[] h = Sm3.Hash(Concat(z, new byte[] { (byte)(ct >> 24), (byte)(ct >> 16), (byte)(ct >> 8), (byte)ct })); Buffer.BlockCopy(h, 0, output, i, Math.Min(32, klen - i)); } return output; }

解密的顺序是:先按配置把三段拆开,用C1和自己的私钥算出共享点(x2, y2),用同样的 KDF 得到t,异或回C2,最后重算C3比对。C3比对必须用固定时间比较(逐字节异或累加),不要用SequenceEqual提前返回,这类细节在安全审计时会被挑出来。

5. 避坑与排查:联调现场最常见的五个问题

这一章是整套实现里最值钱的部分。算法本身照着标准写就行,真正耗时间的是下面这些表现完全一样、原因各不相同的问题。

5.1 验签一直返回 false,但双方代码看着都“对”

现象:自测签名验签往返完全正常,一旦换成对方给的公钥就失败;把对方发来的签名拿去验也失败;两边都不报异常,只是布尔值 false。

原因:九成是 ZA 里的用户 ID 不一致。默认 ID 是 16 个字节的固定串,而对方可能用了自己的应用标识;另一个可能是对方把ZA || M里的M先做了 Base64 解码再哈希,而你直接对字符串字节哈希。

解决:让对方明确给出 ID 的原始字节(不是 Base64,不是十六进制),并且约定M到底是原始报文还是某种规范化之后的字符串。确认后先用SM3(ZA)单独对比一次,把 ZA 的哈希值发给对方核对,这比反复试签名快得多。ZA 一致了,签名再不对才是曲线参数或s负数处理的问题。

5.2 SM4 加密结果第一块对、后面全不对

现象:16 字节以内的短报文加密结果和对方一致,一旦超过 16 字节,第一块密文相同,第二块开始全乱。

原因:CBC 的链式异或写错了。常见两种写法错误,一是用明文块而不是密文块作为下一轮的prev,二是循环里prev更新写在异或之前。

解决:把Buffer.BlockCopy(cipher, i, prev, 0, 16)放在CryptBlock之后、下一轮异或之前,中间不要有别的赋值。验证方法是拿 32 字节的固定输入,手工用第一块密文异或第二块明文,看结果是不是和内部中间值一致。

5.3 SM2 加密偶发“密文长度不对”,重启又好了

现象:接口大部分时间正常,偶尔报密文长度异常,日志里能看到长度是 96 或 98 而期望 97 或 112 之类的值。

原因:BigInteger.ToByteArray()输出的长度不固定。坐标高位为 0 时会少一个字节;如果没用isUnsigned: true,最高位为 1 时还会多出一个0x00符号字节。两种都会让C1的长度在 64、65、66 之间抖。

解决:所有点坐标输出统一走定长 32 字节的转换函数,不要直接调ToByteArray。再在解码侧加一层防御,读到C1时按首字节判断格式(04未压缩、02/03压缩、其余按裸坐标补齐),别硬编码固定偏移量。

5.4 大量数据加密时 CPU 打满,单条报文要几十毫秒

现象:压测时 SM4 的吞吐还能接受,但整个接口 RT 很高,火焰图显示时间集中在 SM2 的标量乘上。

原因:一次 SM2 加解密要做两次点乘(kG和kP),朴素的双倍点算法每次都做模逆,模逆是这里面最贵的一步;另外BigInteger是不可变类型,循环里频繁产生中间对象,GC 压力也上来了。

解决:把点运算改到雅可比坐标下做,只在最后一步转回仿射坐标,一次点乘的模逆从几百次降到 1 次;kG因为基点固定,可以预计算一张表做固定窗口加速。业务侧更好的做法是不用 SM2 直接加密大报文,改成 SM2 加密一把随机的 SM4 密钥,数据走 SM4,性能差距是数量级的。

5.5 升级框架版本后签名结果变了

现象:从旧框架迁到新框架,同样的私钥、同样的报文,对方突然验签失败,而自测往返还是通的。

原因:随机数来源换掉了,或者BigInteger的行为在新运行时上有细微差异。更隐蔽的一种是把k写成了固定值做“可复现测试”,上线忘了改回去,两次签名用了同一个k,从

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进