简介:面向.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字段要求去掉前导零,如果一个值的最高字节大于等于0x80,DER还会额外加一个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的摘要并不是直接对消息计算SM3
SM2签名里需要的中间值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字节,加上开头的0x04,C3占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.Unicode(UTF-16),Python端用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互签、性能基线”这三步。希望这份思路对你也同样有用,能少走一段弯路。
本文还有配套的精品资源,点击获取