
简介面向.NET平台开发者这套资源完整给出C#语言的国密算法实现涵盖SM2非对称加密、SM3哈希摘要、SM4对称加密三大模块并配有WinForm演示程序可直接学习算法原理或用于国产密码改造。压缩包共51个文件以C#源码为核心包含21个.cs文件覆盖算法封装、窗体交互与程序入口同时附有依赖库、Visual Studio解决方案、工程配置、可执行程序及调试信息整体仅5.33MB结构紧凑、便于部署。已有1314人学习。资源价值在于既能通过源码深入理解国密算法的C#实现细节也可直接复用或改造加解密、签名验签等模块快速集成到业务系统WinForm示例界面则直观演示了调用流程大幅降低上手门槛适合有C#基础的安全开发者和算法学习者。1. 国密算法在C#里跑通不是从网上抄一段SM2、SM3、SM4代码就能收工的国密算法在不少政务、金融和信创项目的密码改造里几乎是硬指标SM2负责签名和加解密SM3负责摘要SM4负责对称加密。单看算法本身SM2、SM3、SM4在标准文档里写得清清楚楚可真正把它们落到C#工程里时坑往往不在“算法有多难”而在字节序、编码格式、密文拼接顺序这些看似不起眼的地方。好多团队把代码贴进项目后第一版自测全绿一联调就翻车。这个资源提供了一套C#实现覆盖SM2、SM3、SM4的完整流程并配了配套的测试数据和工具函数适合正在做国产化适配、被联调逼到怀疑人生的.NET开发者和架构师。换句话说它是能让你少加两周班的那份“后悔药”。2. 把编码层先理顺HEX、Base64、ASN.1和大整数字节序是国密联调的第一道坎2.1 为什么先处理HEX和Base64而不是先写算法函数很多国密C#实现一拿到手开发者习惯直接跳到SM2签名、SM4加密那些“看起来很酷”的函数。但实际上跨平台联调失败有相当比例是卡在数据的文本表示上。SM2的私钥、SM3的摘要、SM4的密文在日志里通常以HEX字符串形式出现在网络接口里又常用Base64传递。这两者一旦混用或写错编码后果就是“两边算法都对结果谁也认不出谁”。HEX编码比较直观一个字节对应两个十六进制字符数据体积膨胀一倍胜在可读性好。调试时我喜欢把密钥和摘要都打印成HEX方便肉眼对数据。SM3摘要固定32字节用HEX表示就是64个字符对起来非常顺手。而Base64更紧凑适合放在JSON里传输C#里直接用Convert.ToBase64String就能处理但注意它操作的是byte[]不是字符串。很多人在这一步用Encoding.UTF8.GetBytes把一个字符串直接塞进去等到对方说“摘要对不上”时才发现自己把原文的UTF8字节当成了待编码数据属于低级但高发的错。public static string BytesToHex(byte[] bytes) { StringBuilder sb new StringBuilder(bytes.Length * 2); foreach (byte b in bytes) sb.Append(b.ToString(X2)); return sb.ToString(); } public static byte[] HexToBytes(string hex) { if (hex.Length % 2 ! 0) throw new ArgumentException(HEX字符串长度必须为偶数); byte[] result new byte[hex.Length / 2]; for (int i 0; i result.Length; i) result[i] Convert.ToByte(hex.Substring(i * 2, 2), 16); return result; }BytesToHex里用X2强制输出两位大写十六进制这是与大多数密码工具默认输出保持一致的写法。HexToBytes里我故意先检查长度奇偶因为联调时偶尔会遇到对方把前导零丢掉的情况比如一个字节0x0A被传成A这一步能立刻暴露问题。2.2 BigInteger在C#里的字节序陷阱如果说HEX和Base64只是热身那BigInteger才是C#国密实现里真正的坑中之坑。Java的BigInteger和C#的BigInteger看起来相似行为却差得很远。C#的BigInteger.ToByteArray()返回的是小端序字节数组并且带有符号位如果数值的最高位是1还会额外多出一个字节的0x00。这套规则用在日常大整数运算没问题但SM2椭圆曲线运算所需的坐标和标量都是“大端序、无符号、定长”的形式直接拿ToByteArray()的结果去拼签名出来的字节序列完全是反的。public static byte[] ToUnsignedBigEndian(BigInteger value, int length) { byte[] littleEndian value.ToByteArray(); // 小端、可能有符号位 if (littleEndian.Length 0 littleEndian[^1] 0) Array.Resize(ref littleEndian, littleEndian.Length - 1); // 去掉符号字节 Array.Reverse(littleEndian); // 翻转成大端 if (littleEndian.Length length) return littleEndian; if (littleEndian.Length length) throw new ArgumentException(数值超出目标长度); byte[] result new byte[length]; Buffer.BlockCopy(littleEndian, 0, result, length - littleEndian.Length, littleEndian.Length); return result; }这个工具函数的逻辑分三步先去掉ToByteArray()可能附加的符号字节再翻转数组最后在左侧补零到指定长度。SM2的私钥、签名中的r和s都要求定长输出到32字节所以length基本固定为32。这个函数值得放进任何国密工具类的公共层因为后面几乎所有字节拼接都会用到它。我在这上面吃过亏明明签名计算结果是对的转出来的字节流却是反的到对方那边验签永远是失败最后一行一行查才发现是字节序问题。2.3 ASN.1 DER编码SM2签名结果到底该用哪种形态SM2的原始签名结果是r || s的拼接各32字节总共64字节。但不少平台为了兼容已有基础设施要求把签名包装成ASN.1 DER格式的SEQUENCE结构。DER编码本身不复杂就是0x30打头后面跟着长度和内容内容里是两个INTEGER字段分别装r和s。麻烦的是有些平台约定用原始64字节有些平台约定用DER两边如果不事先对齐哪怕签名数学上完全正确也无法验签通过。public static byte[] EncodeSignatureDer(byte[] r, byte[] s) { byte[] rField new byte[r.Length 2]; rField[0] 0x02; rField[1] (byte)r.Length; Array.Copy(r, 0, rField, 2, r.Length); byte[] sField new byte[s.Length 2]; sField[0] 0x02; sField[1] (byte)s.Length; Array.Copy(s, 0, sField, 2, s.Length); byte[] body new byte[rField.Length sField.Length]; Array.Copy(rField, 0, body, 0, rField.Length); Array.Copy(sField, 0, body, rField.Length, sField.Length); byte[] result new byte[body.Length 2]; result[0] 0x30; result[1] (byte)body.Length; Array.Copy(body, 0, result, 2, body.Length); return result; }这个编码器假设r和s都正好32字节这在SM2签名标准里是成立的。但注意证书体系里的INTEGER字段要求去掉前导零如果一个值的最高字节大于等于0x80DER还会额外加一个0x00字节表示正数。好在SM2的r和s都落在[1, n-1]范围内而n的十六进制以FFFFFFFE开头所以r和s基本不会出现“最高位为1”的情况。联调时我最强的建议是在接口文档里明确写死“签名格式为裸64字节r||s”或“使用DER编码”不要给两端留任何自由发挥的空间。3. SM4与SM3实现对称加密和摘要的正确用法别只调一个函数就完事3.1 SM4的分组模式选择与轮密钥计算SM4是分组长度128比特、密钥长度128比特的对称算法整体结构是32轮非线性迭代。C#实现里最容易被忽视的不是轮函数本身而是分组模式。很多人拿来源码看到有SM4EncryptECB就直接用了觉得能用就行。但ECB模式下相同明文会得到相同密文在数据库字段加密和接口报文加密这两个高频场景里属于审计不过关的典型问题。public static byte[] Sm4CbcEncrypt(byte[] key, byte[] iv, byte[] plaintext) { byte[] padded Pkcs7Pad(plaintext, 16); byte[] cipher new byte[padded.Length]; byte[] prev (byte[])iv.Clone(); for (int i 0; i padded.Length; i 16) { byte[] block new byte[16]; for (int j 0; j 16; j) block[j] (byte)(padded[i j] ^ prev[j]); byte[] encBlock Sm4BlockEncrypt(key, block); Array.Copy(encBlock, 0, cipher, i, 16); prev encBlock; } return cipher; }CBC模式的核心是让每个明文块先与上一个密文块异或再进入SM4轮函数。Pkcs7Pad是必须配套实现的它把所有分组都补齐到16字节边界缺几个字节就补几个值为n的字节。这样解密后能靠最后一个字节的值知道该去掉多少填充。IV必须是随机生成且每次加密都不同通常取16字节随机数放在密文头部一起传给对方。密钥和IV的分离管理很重要IV泄露不至于让密文被破解但固定IV会让CBC退化出模式特征。3.2 SM3摘要填充规则和循环左移的实现细节SM3输出256比特摘要消息填充规则和SHA-256类似但内部压缩函数完全不同。一个容易出问题的点是填充时的长度字段它不是以字节为单位而是以“比特数”为单位且用64位大端整数表示。如果原始消息长度正好是64字节的倍数仍然要追加一个0x80再补零到符合(长度1填充) % 64 56的条件。public static byte[] Sm3Pad(byte[] message) { long bitLength (long)message.Length * 8; int paddingLen (int)((message.Length 1) % 64); int zeroLen paddingLen 56 ? 56 - paddingLen : 120 - paddingLen; byte[] result new byte[message.Length 1 zeroLen 8]; Array.Copy(message, result, message.Length); result[message.Length] 0x80; for (int i 0; i 8; i) result[^1 - i] (byte)(bitLength (8 * i)); return result; }当原始长度加1后超出56字节余量时paddingLen会大于56这时需要补满当前块再额外补一个完整块所以zeroLen用了120 - paddingLen这个表达式。这个细节如果照搬SHA-256代码很容易写错因为SM3的块大小和填充位偏移与SHA-256恰好一致但实现时仍要逐行核对。SM3的压缩主循环里大量使用循环左移C#早期版本没有内置的RotateLeft需要自己写(value n) | (value (32 - n))。.NET 7及以上可以用原生的RotateLeft但为了保证这段代码能在老框架上跑我习惯在工具层保留自定义版本。3.3 SM2的摘要并不是直接对消息计算SM3SM2签名里需要的中间值e不是简单地对原始消息做SM3。标准流程是先拼接用户ID、椭圆曲线参数、公钥坐标等信息算出Z值再把Z和原始消息拼在一起做SM3。这里的用户ID在未指定时有默认值1234567812345678但很多项目会在数字证书里指定自己的ID两端必须用同一个ID否则Z值不同后续整个签名都会对不上。public static byte[] Sm2ZValue(byte[] userId, byte[] publicKeyX, byte[] publicKeyY) { byte[] prefix new byte[] { 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // a 高8字节略写 }; // 实际实现需要完整拼接 a、b、Gx、Gy、Px、Py // 顺序固定userId长度(2字节) || userId || a || b || Gx || Gy || Px || Py // 最后对整体做 SM3 return Sm3Hash(entireData); }代码里我故意没展开全部椭圆曲线参数因为这个拼接顺序才是重点userId的长度用两个字节的大端表示然后是ID本身后面依次是SM2曲线参数a、b、点G坐标、公钥点坐标。很多C#实现把这一步封装成GetZ函数但封装过头后调用者根本不知道里面拼了哪些数据联调时一旦ID不一致错误提示只会停在“验签失败”很难定位。4. SM2签名、验签与加解密从曲线参数到字节输出的完整链路4.1 SM2曲线参数与密钥生成SM2使用的椭圆曲线是固定的256比特素域曲线参数p、a、b、n等都在标准里给定。实现时最稳妥的做法是把这些参数硬编码成常量不要从文件或外部配置读取以免被篡改或传错。public static readonly BigInteger P BigInteger.Parse(FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF, NumberStyles.HexNumber); public static readonly BigInteger N BigInteger.Parse(FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123, NumberStyles.HexNumber); public static readonly BigInteger Gx BigInteger.Parse(32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7, NumberStyles.HexNumber); public static readonly BigInteger Gy BigInteger.Parse(BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0, NumberStyles.HexNumber);生成密钥对的本质是随机选取一个d作为私钥范围在[1, n-2]然后通过椭圆曲线点乘计算公钥P d * G。随机数的质量直接决定安全性C#里应该用RandomNumberGenerator.Create()而不是Random类因为后者是伪随机且种子可预测。生成后私钥是32字节大端公钥是一个椭圆曲线点通常以04开头表示未压缩格式后面跟x坐标和y坐标各32字节。4.2 签名运算r和s的计算顺序不能颠倒SM2签名过程是先计算消息摘要e再选一个随机数k计算点(x1, y1) k * G然后r (e x1) mod n。如果r等于0或r k等于n必须重新选k。最后s ((1 d)^-1 * (k - r * d)) mod n。这个r和s都非零的检查不是可选项而是算法安全的强制要求。public static (byte[] r, byte[] s) Sm2Sign(byte[] e, BigInteger privateKey) { BigInteger d privateKey; while (true) { BigInteger k GenerateRandomScalar(); Point point ScalarMult(k, G); BigInteger r (eValue point.X) % N; if (r 0 || (r k) N) continue; BigInteger s (ModInverse(d 1, N) * (k - r * d)) % N; if (s 0) continue; return (ToUnsignedBigEndian(r, 32), ToUnsignedBigEndian(s, 32)); } }GenerateRandomScalar每次生成一个1到n-1之间的随机数。这里有一个很微妙的点签名算法里k的随机性要求比私钥本身还高如果k被复用或可预测攻击者可以直接反推出私钥d。所以调试时哪怕觉得随机数慢也绝对不要把k固定成常数实在需要复现测试结果应该用固定的测试向量而不是固定随机数。签名输出r和s分别定长32字节再按之前讲好的格式拼成64字节或DER编码。验签的流程恰好是签名过程的逆运算先计算t (r s) mod n再算点(x1, y1) s * G t * P最后验证(e x1) mod n是否等于r。public static bool Sm2Verify(byte[] e, byte[] rBytes, byte[] sBytes, byte[] publicKeyX, byte[] publicKeyY) { BigInteger r new BigInteger(rBytes, true, true); BigInteger s new BigInteger(sBytes, true, true); BigInteger t (r s) % N; if (t 0) return false; Point p1 ScalarMult(s, G); Point p2 ScalarMult(t, new Point(publicKeyX, publicKeyY)); Point sum PointAdd(p1, p2); if (sum.IsInfinity) return false; BigInteger expectedR (eValue sum.X) % N; return expectedR r; }new BigInteger(rBytes, true, true)这个重载在较新的.NET里表示“无符号、大端”可以少写一次字节序转换但用之前一定要确认目标框架支持。验签不通过时先检查r和s是否在合法范围内再去核对ID、公钥坐标和摘要e按照这个顺序排查通常能快速定位问题。4.3 加密输出顺序C1C3C2不是随便排的SM2加密生成的密文由三部分组成C1是椭圆曲线点C2是密文主体C3是原文摘要。新版标准把排列方式定为C1 || C3 || C2但不少老系统还按C1 || C2 || C3输出。跨平台联调时这个顺序必须和对方确认否则解密方会把摘要字节当成密文来异或出来的明文会是一堆乱码。byte[] cipher new byte[1 32 32 plaintext.Length]; cipher[0] 0x04; // 未压缩点标识 Array.Copy(c1X, 0, cipher, 1, 32); Array.Copy(c1Y, 0, cipher, 33, 32); Array.Copy(c3, 0, cipher, 65, 32); // SM3(原文) Array.Copy(c2, 0, cipher, 97, plaintext.Length); // 异或后的密文这段拼接代码把C1的坐标各占32字节加上开头的0x04C3占32字节C2紧跟在后面。解密方拿到密文后先解析出C1点坐标并计算共享密钥然后用KDF生成密钥流把C2部分异或恢复原文最后对原文做SM3并与C3比对。KDF内部需要循环调用SM3来扩展密钥流长度计数器从1开始每次把C1的坐标字节和计数器拼在一起做摘要截取所需长度。这个循环的判断条件是“已生成字节数是否满足要求”写起来不难但计数器溢出和字节序错位都比较隐蔽。5. 避坑与排查C#国密联调中最难缠的五个具体问题5.1 现象SM2解密得到的明文开头多了一个“00”字节或者整个密钥流错位原因BigInteger.ToByteArray()在数值最高位为1时会附加一个0x00符号字节而小端输出又被直接用来做坐标拼接。某次我在处理一个公钥坐标时取出来的字节数组比预期多一位没有做截断就拼进C1导致解密端解析点坐标错位。解决所有从BigInteger往字节数组转的路径统一走自定义的ToUnsignedBigEndian工具函数先去掉符号字节、翻转、再定长补位。不要在每个调用点单独写转换逻辑否则迟早漏掉一处。5.2 现象两边的SM2签名都能生成和验签但一和Java或OpenSSL互签就失败原因签名格式不一致。一边输出裸的64字节r || s另一边按ASN.1 DER输出SEQUENCE { INTEGER r, INTEGER s }。有时同一套代码里接口层用DER文件签名用裸格式双方各说各话。这个问题属于标准文档没写清楚导致的“联调玄学”排查时最耗时间。解决在接口定义阶段就把签名格式钉死并通过一条测试用例验证。比如约好“所有SM2签名结果一律使用DER编码”那C#端就在生成签名后调用EncodeSignatureDer验签前先判断首字节是0x30还是裸r值。5.3 现象SM4加密上线后被安全审计要求整改理由是“ECB模式不安全”原因实现时只提供了Sm4EncryptECB调用方图省事直接用了。ECB模式下同一密钥、同一明文块生成的密文块相同数据库里如果有大量重复字段攻击者能看出数据分布规律。这类问题不是算法本身错了而是使用方法不合规。解决默认提供并强制走CBC模式IV由随机数生成器产生随密文一起存储或传输。ECB入口保留可以但要在文档里标注“仅用于无IV场景的兼容测试”生产环境不推荐。这个坑我在某个模拟项目X里踩过后来凡是新接的模块都默认CBC。5.4 现象SM2签名的摘要值两边对不上但ID、公钥、原文都确认一致了原因摘要e的计算中没有使用默认ID常量。标准规定ID默认值是1234567812345678也就是16字节的ASCII字符串。有些实现把这个值写成了16个数字的HEX形式长度变成了8字节导致Z值整个变化。还有一种情况是ID拼接时忘了前置两字节的长度字段。解决在Z值计算函数里把“ID长度2字节大端 ID内容”作为一个固定前件检查。Debug时打印出Z值的前16字节和末尾16字节和标准测试向量逐字节比对能快速定位是哪个字段拼错。5.5 现象同一段明文C#算出的SM3结果与Python端偶尔不一样不是每次都不一致原因字符串转字节时用了不同的编码。C#端用的是Encoding.UnicodeUTF-16Python端用utf-8英文字符串两边结果一样一出现中文就崩。这类问题最气人因为它只在特定输入下触发前期自测根本发现不了。解决所有进SM3、SM4、SM2原文的字符串统一在入口处做Encoding.UTF8.GetBytes并把这个约定写进工具类注释。数据流里出现字符串编码不一致后面所有算法结果都会跟着错。从那以后我每次做国密联调先问对方一句话原文的字节序列是怎么来的用什么编码。6. 进阶用法用OpenSSL做交叉验证再给算法实现加一份性能基准算法代码写完后最怕的就是“自己跟自己能对上跟外部平台对不上”。交叉验证的常用做法是引入OpenSSL作为参照物因为它对国密的支持相对完整。先确认本机OpenSSL版本支持SM2、SM3、SM4通过openssl list -providers检查再生成一对SM2测试密钥用它对一段固定原文做签名。openssl dgst -sm3 -sign sm2_private_key.pem -out sig.der data.txt openssl pkeyutl -verify -pubin -inkey sm2_public_key.pem -sigfile sig.der -in data.txt -pkeyopt distid:1234567812345678这里最容易踩的点是distid不传或传错。OpenSSL默认的用户ID和SM2标准默认值一致C#端也得用1234567812345678两边才能对上。拿到OpenSSL生成的签名文件后用C#实现里的Sm2Verify去验签如果通过说明至少“私钥、摘要计算、验签公式、签名格式”这条链路是通的。交叉验证通过后还可以顺手做一次性能基准确认实现没有明显的低效逻辑。用.NET的Stopwatch统计签名一千次的平均耗时通常SM2签名在纯托管代码里毫秒级是正常的如果单次超过几十毫秒就要检查椭圆曲线点乘实现里是不是用了大量递归或低效取模。Stopwatch sw Stopwatch.StartNew(); for (int i 0; i 1000; i) { Sm2Sign(eBytes, privateKey); } sw.Stop(); Console.WriteLine($1000次SM2签名平均耗时: {sw.ElapsedMilliseconds / 1000.0:F2} ms/次);性能数据还有一个用途在接口压力测试时作为基线。国密算法的计算成本比同级别的国际算法要高一点尤其是SM2点乘。如果压测时吞吐不达标优先看是不是每次签名都重新生成随机数、每次加解密都重新展开SM4轮密钥这些操作可以缓存或批量处理。从交叉验证到性能基准这整个过程花不了半天时间却能把“算法实现正确”从自我感觉变成可证明的状态。我那回在做某跨平台系统的国密适配时就是靠OpenSSL的验签结果定位出C1C3C2顺序问题避免了上线后推倒重来。从那以后我每次接入一份新的国密C#资源都会强制走一遍“测向量对齐、OpenSSL互签、性能基线”这三步。希望这份思路对你也同样有用能少走一段弯路。本文还有配套的精品资源点击获取