ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C# SM2加签验签工具封装:搞定国密对接中的格式与编码难题

C# SM2加签验签工具封装:搞定国密对接中的格式与编码难题

简介:面向C#开发者的SM2加签、验签工具包,帮助在Windows平台上快速实现基于椭圆曲线密码体系的国产算法签名与验证,适用于安全敏感型应用开发。工具包共29个文件,约2.75MB,包含C#源码文件、窗体界面、可执行程序及BouncyCastle.Crypto.dll核心加密库,另有配置文件、调试符号等,结构完整,可直接打开Visual Studio工程运行或参考源码进行二次集成。其中BouncyCastle.Crypto.dll负责SM2算法底层运算,开发者可聚焦业务逻辑而不必重复实现密码学细节。通过界面可直观操作加签、验签流程,也能将核心逻辑嵌入自有业务系统,满足等保、国密改造等场景需求。目前已有680人学习下载,适合正在调研SM2落地方式、希望在C#项目中快速集成国密算法的初中级开发者。整体将SM2原理演示与代码实现合二为一,省去从零搭建环境与调试的时间,是实用的工程参考。

1. 把 SM2 加签验签封装成工具:一次交付,省掉整个项目组的重复劳动

做 C# 对接国密改造时,你会发现最耗时间的不是写业务逻辑,而是每个对接方拿出来的 SM2 签名规范都不一样。有的给你十六进制串,有的给你 Base64,有的签名结果是 64 字节的 r||s,有的非要 ASN.1 DER 编码,还有的在原文上先做了摘要再让你签。这套东西每个项目都重写一遍,写的都是同样的坑。把 C# SM2 加签、验签工具整成一个 rar 交付物,本质是把这些边界情况都收拾干净,给出一个稳定的、可复用的接口,让调用方只关心原文和密钥,不关心字节序和编码格式。它解决的是“国密对接时签名验签代码到处复制、改出毛病没人管”的痛点。适合正在做等保改造、政务系统对接、或是接手了国产化适配项目的 .NET 工程师。下面这套方案,我按自己交付过多次的结构来讲。

2. 先搞懂 SM2 签名验签的底层逻辑:为什么你的签名总是对不上

2.1 SM2 签名不是简单的“私钥加密、公钥解密”

常见误解是把 RSA 那套搬到 SM2 上,拿私钥对哈希值做“加密”,拿公钥“解密”回来比对。SM2 完全不是这个路子。SM2 签名基于椭圆曲线离散对数难题,签名过程生成一个随机数 k,然后通过椭圆曲线点乘计算出 r 和 s 两个大整数。验签时把 r、s 和原文摘要代进验签方程,验证等式是否成立——它是不存在“解密”这一说的。

这意味着两个直接后果。第一,签名结果是两个大整数,不是一段密文,落地成字节数组的时候就有排列顺序的问题。第二,签名过程引入了随机数 k,同一份原文同一把私钥,每次签出来的结果都不一样,你没法用“比对签名结果是否相同”来验证,只能调验签接口去验。

还有一层是摘要算法。SM2 签名规范里 Z 值计算要拼接用户 ID、曲线参数和公钥,然后对 Z 值加原文一起算 SM3 摘要。不少对接方在“只对原文做 SM3”还是“按规范算 Z 值再加原文”这两个口径上打架。工具包如果只暴露一个 Sm2Sign(原文) 方法,内部就必须把这条规则定死,不然某天换成别的单位对接,同样的代码会出完全不同的签名结果。

2.2 公私钥的两种主流编码形态:十六进制串和 ASN.1 DER

实战里遇到最多的是公私钥的编码问题。国密局推荐的 SM2 密钥是 32 字节私钥、65 字节未压缩公钥(04 开头,x 和 y 各 32 字节)。但落到具体系统里,各家给的资料五花八门。

常见形态可以整理成一张表:

形态格式说明典型场景
裸十六进制私钥64 个十六进制字符,对应 32 字节大整数很多数据交换平台直接下发 d 值
04 开头的裸公钥130 个十六进制字符,04 + x + y最常见,各家基本统一
PEM 格式私钥-----BEGIN PRIVATE KEY----- 包裹的 DER 数据用 OpenSSL 生成或用工具导出的
PKCS#8 或 PKCS#1 变体私钥外层 ASN.1 结构不同Java 或 .NET 自带类库导出的格式

你写的工具如果只支持一种格式,项目里就会有人拿 Java 那边导出的 PKCS#8 私钥来问你“为什么一直报密钥长度错误”。所以工具第一层要做的就是格式归一化:把各种输入格式统一解析成曲线上的 d 值和公钥点坐标,后续签名验签全部基于解析后的内部结构,不再关心外部格式。

另外要留意 04 开头公钥和 C1C3C2 加密输出的区别。签名工具里只需要 04 开头的公钥,但如果你把加密接口也放进来,就要认清 SM2 加密密文是 C1C3C2 结构而不是 C1C2C3,很多对接文档里这一点会写反。

2.3 Z 值计算和用户 ID:验签不过的第一大元凶

验签失败,十有八九是 Z 值计算口径不一致。SM2 签名规范原文里定义了 Z 值计算:对 ENTLA(用户 ID 的长度)、用户 ID、曲线参数 a、b、Gx、Gy、公钥 Px、Py 做 SM3 摘要。用户 ID 默认值是 1234567812345678,但制定标准时同时说明,具体应用可以自定义。问题是很多对接方文档里不写 ID,默认你没设;另一头可能用了空字符串。

工具里必须把 UserID 作为可配置参数暴露出来,默认用标准值,同时允许调用方指定。我在实际项目里遇到过一个数据交换平台,他们的 UserID 是单位机构代码,不传就验签失败。这类问题不通过工具参数暴露出来,排查起来能浪费一天。

摘要顺序也要注意。规范是先对 Z 值做一次 SM3,再把摘要结果拼接原文,再做一次 SM3 得到最终摘要 e。很多自研实现偷懒,直接对原文做 SM3,这在自测时能通过,因为自签自验同一套逻辑,但一对接正规系统就暴露。

2.4 签名结果的两种编码:r||s 和 DER 的取舍

签名结果是两个 32 字节大整数 r 和 s。落到字节流有两种约定方式:

直接拼接 r||s 是最简单的,总共 64 字节,很多内部系统用这个。但要注意 r 或 s 不足 32 字节时前面要补 0,这个细节容易忽略。另一个是 ASN.1 DER 编码,结构是 SEQUENCE { INTEGER r, INTEGER s },长度不固定,一般在 70 字节左右。Java 的 Signature 类默认生成 DER,你在 C# 里验签就要两条路都支持。

工具里我会把签名接口做成两个层次:底层返回 r 和 s 的 BigInteger,让高级用户自己拼;上层提供 SignToHex 和 SignToBase64,分别对应 r||s 十六进制和 DER 编码。验签同理,做成自动识别格式,先看长度再尝试解析,比让调用方传参更省心。

3. 把工具拆成三层结构:从底层算法到调用方友好接口

3.1 第一层:BouncyCastle 的 SM2 支持到底能不能直接用

C# 里做国密,BouncyCastle 是绕不开的底座。它提供了 Org.BouncyCastle.Crypto.EC.CustomNamedCurves 里的 SM2P256V1 曲线定义,以及 SM3Digest、SM2Signer 这些实现。但直接用原生 API 写业务代码,你很快会发现三个别扭的地方:

第一,SM2Signer 初始化时要把 ID 转成字节数组作为 IParameters 传进去,这个参数很容易被忽略。第二,它生成的签名结果默认是 DER 格式,想要 r||s 得自己解析 DER 结构。第三,密钥参数要从 BigInteger 和 ECPoint 构造,而手头的私钥是十六进制字符串,还要自己把字符串转成曲线点。

所以底层封装要做的就是把 BouncyCastle 的这些“能跑但不顺手”的 API 包一层,对外只暴露简单方法。我用的是自定义类库,命名为 SM2CryptoService,内部持有曲线参数对象,避免每次签名都重新查表加载曲线。

3.2 第二层:密钥解析器的四个入口,覆盖实战中 95% 的密钥格式

密钥解析是工具的核心价值之一,我给它做了四个静态入口:

public static SM2KeyPair ParseFromHex(string privateKeyHex, string publicKeyHex) { var curve = CustomNamedCurves.GetByName("sm2p256v1"); var domain = new ECDomainParameters(curve, curve.G, curve.N, curve.H); var d = new BigInteger(1, Hex.Decode(privateKeyHex)); var q = curve.Curve.DecodePoint(Hex.Decode(publicKeyHex)); return new SM2KeyPair( new ECPrivateKeyParameters(d, domain), new ECPublicKeyParameters(q, domain) ); }

这个入口要求私钥是 32 字节裸十六进制、公钥是 04 开头的裸公钥,是最常用的形态。逻辑上先拿到曲线定义,再分别解码私钥大整数和公钥曲线点,最后构造出 BouncyCastle 的密钥参数对象。参数说明:Hex.Decode 是 BouncyCastle 自带的十六进制解码工具,不要自己写;DecodePoint 会检查公钥长度和解压格式,如果公钥不是 04 开头会直接抛异常。

public static SM2KeyPair ParseFromPem(string privateKeyPem, string publicKeyPem = null) { var reader = new StringReader(privateKeyPem); var pemReader = new PemReader(reader); var pemObject = pemReader.ReadObject(); if (pemObject is AsymmetricCipherKeyPair keyPair) { var privKey = (ECPrivateKeyParameters)keyPair.Private; var pubKey = (ECPublicKeyParameters)keyPair.Public; return new SM2KeyPair(privKey, pubKey); } throw new InvalidOperationException("PEM 文件格式不可识别"); }

PEM 入口兼容 OpenSSL 生成的密钥对。注意 PemReader 读出来可能是 AsymmetricCipherKeyPair(同时含公私钥),也可能是 ECPrivateKeyParameters(只有私钥),要分情况处理。实战中 Java 系统导出的私钥往往只有私钥部分,公钥要另外传入。

第三个入口是解析 PKCS#8 格式的。PKCS#8 里 SM2 私钥的 OID 是 1.2.156.10197.1.301,解析时要用 PrivateKeyInfoFactory。第四个入口是从公钥坐标直接构造,x 和 y 各 32 字节可以用两个 BigInteger 直接构造 ECPoint,适用于对接方只给你 x、y 坐标的文档场景。

3.3 第三层:加签验签的对外接口,把编码差异全部吞掉

对外接口我设计成四种组合,全部以字符串形式输入输出,这样调用方不需要引用任何加密库的类型:

public static string SignHex(string privateKeyHex, string data, string userId = null) { var keyPair = ParseFromHex(privateKeyHex, null); var signer = new SM2Signer(); var parameters = new ParametersWithID( new ParametersWithRandom(keyPair.Private, new SecureRandom()), Encoding.UTF8.GetBytes(userId ?? DEFAULT_USER_ID) ); signer.Init(true, parameters); var dataBytes = Encoding.UTF8.GetBytes(data); signer.BlockUpdate(dataBytes, 0, dataBytes.Length); var derSign = signer.GenerateSignature(); // 将 DER 格式转换成 r||s 64字节十六进制 var r = derSign[0]; var s = derSign[1]; var rBytes = r.ToByteArrayUnsigned(); var sBytes = s.ToByteArrayUnsigned(); // 统一补足 32 字节 var padded = new byte[64]; Buffer.BlockCopy(rBytes, 0, padded, 32 - rBytes.Length, rBytes.Length); Buffer.BlockCopy(sBytes, 0, padded, 64 - sBytes.Length, sBytes.Length); return Hex.ToHexString(padded); }

这段代码有四个关键点。SM2Signer.Init 的第一个参数 true 表示签名模式,false 是验签模式,写反了不会报错,但结果会对不上。ParametersWithID 嵌套了两层,外层是 ID,内层是带随机数的私钥参数。GenerateSignature 返回的是 BigInteger 数组,下标 0 是 r,下标 1 是 s。最后一步补零很关键,如果 r 或 s 的实际位数不足 256 位,不补零会变短,对方验签时按 32 字节解析就会错位。

验签接口对称实现:

public static bool VerifyHex(string publicKeyHex, string data, string signatureHex, string userId = null) { var keyPair = ParseFromHex(null, publicKeyHex); var signer = new SM2Signer(); var parameters = new ParametersWithID(keyPair.Public, Encoding.UTF8.GetBytes(userId ?? DEFAULT_USER_ID)); signer.Init(false, parameters); var dataBytes = Encoding.UTF8.GetBytes(data); signer.BlockUpdate(dataBytes, 0, dataBytes.Length); // 先尝试按 r||s 解析 var sigBytes = Hex.Decode(signatureHex); if (sigBytes.Length == 64) { var r = new BigInteger(1, sigBytes, 0, 32); var s = new BigInteger(1, sigBytes, 32, 32); return signer.VerifySignature(r, s); } // 否则尝试 DER 格式解析 try { var derSig = DerSequence.GetInstance(sigBytes); var r = ((DerInteger)derSig[0]).Value; var s = ((DerInteger)derSig[1]).Value; return signer.VerifySignature(r, s); } catch (Exception) { return false; } }

验签接口做了一个自动识别。先判断长度,64 字节按 r||s 处理;否则尝试按 DER 解析。这个设计避免了调用方在两种格式之间传错参数的问题。DER 解析用 Asn1Sequence.GetInstance 而不是手动掐头去尾,因为 DER 里 r 或 s 可能带符号位补充字节,手动解析很容易踩坑。

3.4 顺手加一个 SM3 哈希工具和文件签名重载

既然做了 SM2,SM3 几乎是必然配套的。我在工具包里放了一个 Sm3Helper,用来暴露摘要能力。SM3 摘要结果是 32 字节 64 位十六进制,这个在很多对接场景里要用来做数据完整性校验,而 SM2 签名前实际上也要算摘要,只是被封装在 SM2Signer 内部了。

文件签名是另一个高频需求。对接方有时要求对文件的原始字节做签名,而不是对文件内容的字符串做签名。我把接口重载成:

public static string SignFile(string privateKeyHex, string filePath, string userId = null) { var bytes = File.ReadAllBytes(filePath); return SignBytes(privateKeyHex, bytes, userId); }

这里的重点是文件读取要按二进制读,不能用 ReadAllText。有个项目对接时,对方对 PDF 文件做签名,我方按 UTF-8 文本读入再转字节,两边算出来的摘要完全不同。文件签名时建议额外记录文件长度和最后修改时间,排查问题时有参照。

4. 用命令行程跑通最小验证:从签名到验签的完整闭环

4.1 搭建一个 30 行的命令行 Demo,验证工具包本身可用

工具类库写好后,第一步要验证它自己签出来的签名自己能验过。我习惯在解决方案里加一个 Console 项目做自测,代码很短但覆盖面要够:

static void Main(string[] args) { // 测试向量:用了国密标准文档里的官方密钥对 const string privateKeyHex = "3945208F7B2144B13F36E38AC6D39F95889393692860B51A42FB81EF4DF7C5B8"; const string publicKeyHex = "09F9DF311E5421A150DD7D161E4BC5C672179FAD1833FC076BB08FF356F35020" + "CCEA490CE26775A52DC6EA718CC1AA600AED05FBF35E084A6632F6072DA9AD13"; const string userId = "1234567812345678"; const string message = "国密SM2签名验签测试"; var signature = Sm2Service.SignHex(privateKeyHex, message, userId); Console.WriteLine($"签名结果: {signature}"); Console.WriteLine($"签名长度: {signature.Length}"); var valid = Sm2Service.VerifyHex(publicKeyHex, message, signature, userId); Console.WriteLine($"验签结果: {valid}"); // 篡改测试 var tampered = message + " "; var valid2 = Sm2Service.VerifyHex(publicKeyHex, tampered, signature, userId); Console.WriteLine($"篡改后验签: {valid2}"); }

这里用的测试密钥对来自国密标准文档的示例,不是随便生成的。用标准向量自测好处是如果你的算法实现有误,和标准结果对不上;但只跑自签自验无法发现“两边实现口径不一致”的问题,所以还要准备交叉测试。

4.2 时间戳随机性验证:同一原文签两次,结果必须不同

SM2 签名的随机数特性可以用一个小脚本验证,顺带确认 SecureRandom 工作正常。同一把私钥签同一段原文十次,如果十次结果完全一样,说明随机源出了问题,这在真实场景中等于私钥泄露。

写一个循环,把每次的签名结果收集起来比较:

var signatures = new List<string>(); for (var i = 0; i < 10; i++) { signatures.Add(Sm2Service.SignHex(privateKeyHex, message, userId)); } var distinct = signatures.Distinct().Count(); Console.WriteLine($"10次签名去重后: {distinct} 个不同结果");

正常情况下 distinct 应该等于 10。如果出现重复,检查 SecureRandom 的初始化方式,看看是不是把随机种子固定了。这是我在代码审查时经常会发现的问题:有同事为了测试可复现性,把随机种子用固定值初始化,一上线签名就不安全了。生产环境绝不能用固定随机源,这是底线。

4.3 与标准工具交叉验证:用 OpenSSL 拉起一份对照数据

C# 侧自测通过不算数,真正的考验是和其他语言实现交叉验签。常见做法是让 Java 或 OpenSSL 用同样密钥对同一原文签名,然后 C# 工具去验;反过来也成立。

以 OpenSSL 为例,它 3.0 以上版本支持 SM2,操作两步:先用 EC 参数生成 SM2 密钥对,再对原文做签名。验签时把 OpenSSL 产生的签名文件读入,注意它输出的默认是 DER 格式,你工具的自动识别在这时就派上用场了。

这个交叉验证解决了一个隐蔽问题:你的实现可能和自己的验签代码配合良好,但在和外部系统对接时仍然失败。因为双方对 UserID、签名格式、甚至原文编码(UTF-8 还是 GBK)的理解不同。我一般会在对接前准备一个“签名样例清单”,包含原文、私钥、UserID、期望签名值,发给对方确认,两边先对准这个清单再联调。

4.4 在 rar 交付包里附带这个 Demo 项目

整个工具压缩成 rar 交付时,我的目录结构是这样组织的:

SM2Toolkit/ ├── src/ │ ├── SM2Crypto/ # 工具类库源码 │ ├── SM2Demo/ # 命令行自测项目 │ └── SM2WinForm/ # 简单的图形界面 ├── docs/ │ ├── 接口说明.md │ └── 对接样例.md ├── lib/ │ └── BouncyCastle.Cryptography.dll └── README.txt

把源码完整放进去而不是只放编译好的 dll,是考虑到接手的人可能要改 UserID 处理逻辑或是增删编码格式。docs 目录里我会写清楚每个接口的输入输出示例,这样不熟悉国密的后端也能照着对接。dll 目录放依赖是为了让 Demo 解压就能跑,不需要现场翻 NuGet。

5. 常见问题与避坑指南:SM2 联调现场的血泪经验

5.1 签出来的长度不对:64 位还是 70 位十六进制

现象:对方说收到的签名长度不是 128 个十六进制字符,或者你收到对方的签名发现是 140 个字符。原因:签名编码一个是 r||s 拼接(64 字节 = 128 位十六进制字符),一个是 DER 编码(长度不固定,通常 70 字节左右 = 140 位十六进制字符)。解决:先问清楚对方要哪种格式,或者工具验签时自动兼容。我的经验是:如果是对接正规的密码机或第三方平台,多数用 r||s 十六进制;如果是 Java 的 Signature 类标准 API 生成的,多半是 DER。设计上让验签自动识别两种格式,能让联调阶段少一半扯皮。

5.2 验签一直返回 false,但双方都说自己的实现没问题

现象:两边各自自测都通过,交叉验签就是不过。原因:九成是 UserID 不一致,剩下的可能是摘要算法顺序、原文编码。常见的是 A 系统用默认的 1234567812345678,B 系统用空字符串或者机构代码。解决:把 UserID 作为参数显式传给接口,并且先问清楚对接方的 UserID 是什么。如果对方自己也不明确,建议用默认值并两端确认。另一个隐藏点是 UserID 要转成 UTF-8 字节再参与运算,有些系统用了 GBK 编码,也会导致摘要对不上。

5.3 公钥解析报错“invalid point coordinates”

现象:ParseFromHex 时 DecodePoint 抛出异常。原因:公钥长度不对,或者不是 04 开头。有些系统会把公钥去掉 04 前缀,只传 128 位十六进制字符串(x 和 y 拼接),DecodePoint 不认识这种格式。解决:解析入口里加一层自动补全:如果长度是 128,自动在前面拼 04 再解析。还要注意有些系统把 04 写成 04 的十六进制形式即 3034,要仔细看字符串内容,不能只看开头两位字符是不是 04。

5.4 签名结果每次不同,导致对方无法比对

现象:对方拿着你第一次签名的结果做字符串比对,发现第二次签名结果不同,判定“签名不稳定”。原因:这不是 bug,SM2 签名本来就有随机性。但这说明对方对 SM2 特性不够了解,如果你的工具文档里没写清楚,会白白消耗双方排查时间。解决:文档里明确写“SM2 签名具有随机性,同一原文签名结果不同属于正常现象”,并且在 README 里给出一个验签步骤,教对方怎么判断签名有效性,而不是比较字符串。这个解释在联调第一天就要发给对方。

5.5 离线包解压后运行报缺少依赖

现象:用户解压 rar 后运行 Demo,提示找不到 BouncyCastle.Cryptography.dll 或者版本冲突。原因:BouncyCastle 有两个命名空间的版本并存,旧包命名空间是 Org.BouncyCastle,新包改了命名空间,二者不能同时引用。解决:交付的 lib 目录里放一个锁定版本的 dll,并在 README 里写明目标框架是 .NET Framework 4.6.1 还是 .NET 6/8,不同框架下依赖行为不同。另一个踩坑是 x86 和 x64 的 AnyCPU 切换问题,如果目标机器是 32 位进程,要注意某些 BC 版本在 32 位下使用 SecureRandom 时的兼容性。

5.6 大文件签名时内存暴涨

现象:对几百 MB 文件签名,程序内存占用突增,甚至 OOM。原因:一次性把整个文件读入内存再签名。解决:改用流式更新,SM2Signer 支持 BlockUpdate 分段喂数据,但要注意文件哈希计算和签名计算要同步做。更稳妥的方式是先算出文件 SM3 摘要,再对摘要做签名,因为很多对接方接受的是“先摘要后签名”模式。我在工具里提供了两个文件签名入口:一个直接签名原文件字节,一个先摘要再签名,对接时按对方要求选择。

5.7 私钥以字符串硬编码在代码里

现象:代码仓库里躺着明文的 SM2 私钥十六进制字符串。原因:开发阶段图省事,直接把测试私钥写在常量里忘了移除。解决:工具提供从加密配置读取私钥的能力,比如从环境变量或者独立的密钥配置文件读取,并做基本混淆。但坦白说,纯 C# 程序里的私钥保护只能做到增加门槛,真正要安全得用硬件密码机或 KSP 存储,工具层面只能做好接口约定。

6. 进阶:生成密钥对、签名结果归一化和自动化回归测试

6.1 给工具加上 SM2 密钥对生成器

对接时对方经常说“你生成一对密钥给我”,所以工具要内置生成能力。用 SecureRandom 生成 32 字节私钥大整数,确保它在 1 到 n-1 之间,然后做椭圆曲线点乘得到公钥:

public static SM2KeyPair GenerateKeyPair() { var curve = CustomNamedCurves.GetByName("sm2p256v1"); var domain = new ECDomainParameters(curve, curve.G, curve.N, curve.H); var secureRandom = new SecureRandom(); var d = new BigInteger(256, secureRandom); // 确保私钥在合法范围内 d = d.Mod(domain.N.Subtract(BigInteger.One)).Add(BigInteger.One); var q = curve.G.Multiply(d).Normalize(); return new SM2KeyPair( new ECPrivateKeyParameters(d, domain), new ECPublicKeyParameters(q, domain) ); }

这里要注意 d 的范围检查。BigInteger(256, random) 生成的是 0 到 2^256-1 的随机数,直接用它当私钥有极小概率为 0,必须先 Mod 再加 1。生成结果导出时,私钥输出 64 位十六进制,公钥输出 04 开头的 130 位十六进制,并打印一份 PEM 格式,方便导入 OpenSSL。

6.2 签名结果归一化:把 DER 转成 r||s 的标准方法

不少对接场景要求统一成 r||s 格式,但 Java 那边给过来的是 DER。写一个转换函数很有用:

public static string DerToRawHex(string derHex) { var derBytes = Hex.Decode(derHex); var seq = DerSequence.GetInstance(derBytes); var r = ((DerInteger)seq[0]).Value.ToByteArrayUnsigned(); var s = ((DerInteger)seq[1]).Value.ToByteArrayUnsigned(); var raw = new byte[64]; Buffer.BlockCopy(r, 0, raw, 32 - r.Length, r.Length); Buffer.BlockCopy(s, 0, raw, 64 - s.Length, s.Length); return Hex.ToHexString(raw); }

这里的核心陷阱是 DerInteger 的 Value 在处理负数时会反码,所以要用 ToByteArrayUnsigned。做转换前先验证 DER 结构合法性,避免对方发来一段乱码导致解码异常被当成业务报错。

6.3 自动化回归测试:一组固定向量守住工具不回归

工具交付后会被反复修改,改一次挂一次的情况很常见。我写完工具后会写一组 NUnit 回归测试,把曲线参数、密钥对、UserID、原文和期望的签名摘要锁死。这样任何人动过底层解析代码,一跑测试就知道改坏了哪里。

测试用例不用太多,五个就够:标准向量签名验签、短私钥补零场景(私钥开头是 00 的用例)、DER 格式兼容验签、错 UserID 必须验签失败、篡改原文必须验签失败。每个用例都写清楚注释,说明对应的是实际项目里哪个坑。这组测试随着 rar 包一并交出去,对方改代码时也能自保。

6.4 我对这个工具的使用习惯

这套工具我迭代过几个版本,最大的感悟是:国密对接的难题不在算法,而在“口径”。密钥格式、签名编码、UserID、原文编码,这四个变量只要对上一个项目就是成功。我现在的交付习惯是拿到对接文档先做一份“参数确认清单”,把这四个变量列出来发给对方书面确认,然后才动手写代码。这个习惯帮我避开过不少返工。

如果你打算在自己的项目里做 SM2 加签验签,不用重复造轮子,把上面的解析器和接口照着自己的项目结构整理一遍,重点保住密钥格式兼容和签名格式自动识别这两条。前者让你不挑对接方,后者让你的联调顺畅许多。从这套结构开始,再按自己项目的依赖管理方式把 BouncyCastle 引进来,跑通一次签名验签闭环,剩下的事情就都是体力和联调了。希望帮到你。

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

RELATED READING

延伸阅读

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