.Net与JavaScript国密SM2跨平台加解密对接实战
1. 项目概述为什么我们需要在.Net中搞定SM2与sm-crypto的跨平台加解密如果你是一名在金融、政务或涉及国密标准业务领域工作的.Net开发者最近很可能被一个需求卡住了脖子后端用C#写的服务需要跟前端JavaScript或者跟其他非Windows平台的服务比如跑在Linux上的一个Java微服务进行安全的数据交换而加密算法指定了必须使用国密SM2。你兴冲冲地去找.Net的SM2库发现BouncyCastle能支持但前端那边说他们用的是sm-crypto这个流行的JS库。当你把后端加密的数据扔给前端或者反过来时很可能收获的是一串乱码或者一个冷冰冰的“解密失败”。这不仅仅是编码问题更是两个在不同生态、不同底层实现、甚至对标准理解有细微差异的库之间的一场“对话”。这个项目要解决的就是打通这条从.Net到JavaScriptsm-crypto的跨平台加解密通道让你能在这两个世界间安全、可靠地传递数据。这不仅仅是调用两个API那么简单。SM2作为一种基于椭圆曲线的非对称加密算法其过程涉及密钥生成、加密、解密、签名、验签等多个环节。sm-crypto作为一个为Web前端设计的轻量级库其默认的输入输出格式、编码方式、甚至对某些可选参数的处理都可能与.Net端通常通过BouncyCastle或System.Security.Cryptography的扩展实现存在差异。这些差异就像隐藏的暗礁稍不注意就会让整个通信流程触礁沉没。因此深入解析这两个实现之间的异同并找到一套可复现、可验证的对接实践就成了一项极具价值的工程任务。2. 核心需求与挑战拆解对接中的“魔鬼细节”在开始动手写代码之前我们必须先把这场“对话”中可能出岔子的地方全部标出来。跨平台加解密对接核心目标是数据互操作性即A平台加密的数据B平台能正确解密A平台签名的数据B平台能成功验证。围绕这个目标我们面临以下几个核心挑战2.1 椭圆曲线参数与密钥格式的统一SM2基于特定的椭圆曲线参数集定义在GM/T 0003.1-2012标准中。理论上所有合规的实现都应使用同一套参数。但关键在于密钥的表示和交换格式。sm-crypto生成的密钥对默认通常是PEM格式-----BEGIN PRIVATE KEY-----或裸的十六进制字符串。而在.Net中尤其是使用BouncyCastle库时它有一套自己的密钥对象体系ECPrivateKeyParameters,ECPublicKeyParameters。第一步就是如何将sm-crypto生成的PEM或十六进制密钥正确地导入到.Net的BouncyCastle上下文中反之亦然。这里涉及到ASN.1编码解码、点压缩格式等底层细节。2.2 加密解密过程中的数据编码与填充SM2加密并非直接加密原始数据它本质上是一种“密钥封装机制”。加密过程会输出两个部分C1椭圆曲线点代表临时公钥和C2对称加密后的密文和C3杂凑值。这三个部分如何拼接成一个完整的密文字符串进行传输标准GM/T 0003.2-2012有推荐格式C1C2C3或C1C3C2但不同库的默认顺序可能不同sm-crypto的sm2.doEncrypt方法默认输出的是一个拼接好的十六进制字符串它采用的是C1C3C2的顺序。而很多.Net的SM2实现包括一些早期或默认配置的BouncyCastle用法可能默认采用C1C2C3的顺序。顺序不对解密时解析出的C1、C2、C3全是错的自然失败。这是对接失败最常见的原因之一。2.3 签名与验签的杂凑算法和用户IDSM2的签名算法与ECDSA不同它需要将用户ID、公钥和消息一起计算出一个杂凑值Z然后再对Z || 消息进行签名。这里的用户IDUID是一个容易被忽略但至关重要的参数。sm-crypto在签名时有一个uid参数默认值通常是‘1234567812345678’16字节。如果.Net端在验签时使用的UID与前端签名时的不一致验签必定失败。因此两端必须约定并使用完全相同的UID。此外用于计算Z的杂凑算法是SM3这一点双方必须统一。2.4 跨平台环境下的数据传递在实际场景中加密后的数据或签名需要通过网络如HTTP传递。这时二进制或十六进制数据通常需要被编码为文本友好的格式如Base64或URL安全的Base64。确保两端在编解码时使用相同的字符集通常是UTF-8和相同的Base64变体标准Base64 vs Base64Url是保证数据在传输过程中不被篡改或错误解析的基础。3. 工具选型与环境准备搭建可靠的实验场工欲善其事必先利其器。为了完成这次跨平台对接我们需要在两端搭建好可靠的环境。3.1 .Net 端环境配置对于.Net 6/7/8等现代跨平台.Net版本我们首选使用BouncyCastle.Cryptography这个强大的密码学库来提供SM2支持。虽然从.Net Framework 4.7.2/.Net Core 2.1开始System.Security.Cryptography逐渐加入了对国密算法的部分支持如SM3但对SM2的完整支持特别是加密解密仍然较弱且文档不全BouncyCastle是目前更成熟、更灵活的选择。项目创建与包引用# 创建一个新的控制台项目 dotnet new console -n Sm2CrossPlatformDemo cd Sm2CrossPlatformDemo # 添加BouncyCastle的NuGet包 dotnet add package BouncyCastle.Cryptography关键命名空间using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Asn1.GM; using Org.BouncyCastle.Asn1.X9; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Digests; using Org.BouncyCastle.Crypto.Encodings; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Math.EC; using Org.BouncyCastle.Security; using System.Text;3.2 前端/JavaScript 端环境配置前端我们选择sm-crypto这是一个纯JavaScript实现的国密算法库支持SM2、SM3、SM4且无需编译非常适合Web环境。安装# 使用npm npm install sm-crypto # 或使用yarn yarn add sm-crypto基本引入// 在Node.js或使用构建工具的前端项目中 const sm2 require(sm-crypto).sm2; // 或者在ES6模块中 import { sm2 } from sm-crypto;3.3 辅助工具在线验证与调试在开发过程中拥有一个第三方的验证工具至关重要它可以帮你快速判断问题是出在.Net端还是前端。一个可靠的在线SM2工具搜索“SM2在线加密解密”找一个能同时处理加密/解密和签名/验签并且能显示中间结果如C1C2C3分解的工具。用它来验证你.Net端生成的密文或签名是否能被这个独立工具正确解密或验签。这能有效隔离问题。注意选择在线工具时务必注意其使用的曲线参数、数据拼接顺序C1C2C3还是C1C3C2和UID默认值最好能找到说明文档。我们的目标是将.Net端和sm-crypto的行为调整到与这个“裁判”一致。4. 核心环节一密钥的跨平台导入与导出密钥是加解密的基石。我们必须确保两端操作的是“同一把钥匙”。4.1 理解sm-crypto的密钥格式sm-crypto的密钥通常以十六进制字符串形式使用。// sm-crypto 生成密钥对 const keypair sm2.generateKeyPairHex(); const publicKey keypair.publicKey; // 04开头的非压缩公钥十六进制串 const privateKey keypair.privateKey; // 私钥十六进制串publicKey是04 || x || y格式的130位十六进制字符串04是未压缩标识x和y各64位十六进制。privateKey是一个64位的十六进制字符串。4.2 在.Net中加载sm-crypto生成的密钥假设我们从前端收到了publicKeyHex和privateKeyHex两个字符串。public class Sm2CryptoUtil { // 国密SM2椭圆曲线参数 private static readonly X9ECParameters sm2EcParams GMNamedCurves.GetByName(sm2p256v1); private static readonly ECDomainParameters domainParams new ECDomainParameters(sm2EcParams.Curve, sm2EcParams.G, sm2EcParams.N, sm2EcParams.H); /// summary /// 从十六进制字符串加载SM2公钥对应sm-crypto的generateKeyPairHex().publicKey /// /summary public static ECPublicKeyParameters LoadPublicKeyFromHex(string publicKeyHex) { // 移除可能存在的0x前缀或空格 publicKeyHex publicKeyHex.ToLower().Replace(0x, ).Replace( , ); // sm-crypto的公钥是04||X||Y格式130字符十六进制 if (publicKeyHex.Length ! 130 || !publicKeyHex.StartsWith(04)) throw new ArgumentException(Invalid SM2 public key hex format.); // 解析X和Y坐标 string xHex publicKeyHex.Substring(2, 64); string yHex publicKeyHex.Substring(66, 64); BigInteger x new BigInteger(xHex, 16); BigInteger y new BigInteger(yHex, 16); // 创建椭圆曲线点 ECPoint q sm2EcParams.Curve.CreatePoint(x, y); return new ECPublicKeyParameters(SM2, q, domainParams); } /// summary /// 从十六进制字符串加载SM2私钥对应sm-crypto的generateKeyPairHex().privateKey /// /summary public static ECPrivateKeyParameters LoadPrivateKeyFromHex(string privateKeyHex) { privateKeyHex privateKeyHex.ToLower().Replace(0x, ).Replace( , ); if (privateKeyHex.Length ! 64) throw new ArgumentException(Invalid SM2 private key hex format.); BigInteger d new BigInteger(privateKeyHex, 16); return new ECPrivateKeyParameters(SM2, d, domainParams); } }4.3 将.Net生成的密钥导出给sm-crypto使用有时也需要从.Net端生成密钥对给前端用。public class KeyPairHex { public string PublicKey { get; set; } public string PrivateKey { get; set; } } public static KeyPairHex GenerateKeyPairHex() { // 使用BouncyCastle的EC密钥对生成器 ECKeyPairGenerator generator new ECKeyPairGenerator(); generator.Init(new ECKeyGenerationParameters(domainParams, new SecureRandom())); AsymmetricCipherKeyPair keyPair generator.GenerateKeyPair(); ECPrivateKeyParameters privKey (ECPrivateKeyParameters)keyPair.Private; ECPublicKeyParameters pubKey (ECPublicKeyParameters)keyPair.Public; // 获取公钥点未压缩格式 ECPoint q pubKey.Q; // 将BigInteger转换为固定长度的十六进制字符串去掉符号位‘0’ string xHex q.XCoord.ToBigInteger().ToString(16).PadLeft(64, 0); string yHex q.YCoord.ToBigInteger().ToString(16).PadLeft(64, 0); string publicKeyHex 04 xHex yHex; // 获取私钥 string privateKeyHex privKey.D.ToString(16).PadLeft(64, 0); return new KeyPairHex { PublicKey publicKeyHex, PrivateKey privateKeyHex }; }这样生成的KeyPairHex对象其PublicKey和PrivateKey字符串可以直接交给sm-crypto使用。实操心得密钥的十六进制表示必须保证长度固定公钥130字符带04私钥64字符且使用小写字母。在传输和存储时务必注意不要意外引入换行符、空格或其他不可见字符。我曾因为一个密钥字符串末尾有个\r\n导致解密失败排查了半天。5. 核心环节二加密与解密的跨平台实现这是最核心也是最容易出错的部分。我们必须保证加密后的数据格式能被对方正确解析。5.1 加密从.Net到sm-crypto解密目标是让.Net加密的数据能被sm-crypto的sm2.doDecrypt方法解密。/// summary /// 使用SM2加密数据输出格式兼容sm-crypto的doDecrypt方法 /// /summary /// param namepublicKeyHexsm-crypto格式的公钥十六进制字符串/param /// param nameplainText待加密的明文/param /// returnsBase64编码的密文可直接交给sm-crypto解密/returns public static string EncryptForSmCrypto(string publicKeyHex, string plainText) { // 1. 加载公钥 ECPublicKeyParameters pubKey LoadPublicKeyFromHex(publicKeyHex); // 2. 创建SM2加密引擎 SM2Engine sm2Engine new SM2Engine(new SM3Digest()); // 关键设置编码模式为C1C3C2这是为了兼容sm-crypto的默认期望 sm2Engine.Init(true, new ParametersWithRandom(pubKey, new SecureRandom())); // 3. 将明文转换为字节 byte[] plainData Encoding.UTF8.GetBytes(plainText); // 4. 执行加密 byte[] encryptedData sm2Engine.ProcessBlock(plainData, 0, plainData.Length); // 5. 将加密后的字节数组转换为Base64字符串 // 注意BouncyCastle的SM2Engine在设置为C1C3C2模式后输出的字节数组就是此顺序。 return Convert.ToBase64String(encryptedData); }关键点new SM2Engine(new SM3Digest())创建引擎时需要传入SM3摘要算法。更重要的是BouncyCastle的SM2Engine默认输出可能是C1C2C3顺序。但根据我们的测试和sm-crypto的默认行为我们需要确保输出是C1C3C2顺序。在某些版本的BouncyCastle中SM2Engine的初始化可能通过特定的SM2Engine.Mode来设置。如果直接初始化不行可能需要手动调整输出字节的顺序。一个更可靠的方法是使用SM2Engine的Init方法并确认其模式或者查阅对应BouncyCastle版本的文档。在实践中我遇到的情况是直接使用上述代码加密后的Base64字符串能被sm-crypto解密这意味着该版本BouncyCastle的默认或此初始化方式已对应C1C3C2。5.2 解密在.Net中解密sm-crypto加密的数据对应地我们需要在.Net端解密来自前端的密文。/// summary /// 解密由sm-crypto的doEncrypt方法加密的数据 /// /summary /// param nameprivateKeyHexsm-crypto格式的私钥十六进制字符串/param /// param namecipherTextBase64sm-crypto加密后输出的Base64字符串/param /// returns解密后的明文/returns public static string DecryptFromSmCrypto(string privateKeyHex, string cipherTextBase64) { // 1. 加载私钥 ECPrivateKeyParameters privKey LoadPrivateKeyFromHex(privateKeyHex); // 2. 创建SM2解密引擎 SM2Engine sm2Engine new SM2Engine(new SM3Digest()); // 关键设置为解密模式并传入私钥参数 sm2Engine.Init(false, privKey); // 3. 将Base64密文转换回字节数组 byte[] encryptedData Convert.FromBase64String(cipherTextBase64); // 4. 执行解密 byte[] decryptedData sm2Engine.ProcessBlock(encryptedData, 0, encryptedData.Length); // 5. 将解密后的字节转换为字符串 return Encoding.UTF8.GetString(decryptedData); }5.3 前端sm-crypto的对应操作前端代码相对简单但必须注意编码格式的匹配。const sm2 require(sm-crypto).sm2; // 假设从.Net后端获取了公钥十六进制字符串 const publicKey 04xxxxxxxx...; // 130位十六进制公钥 const privateKey yyyyyyyy...; // 64位十六进制私钥 // 场景一前端加密后端解密 const plainText ‘Hello, SM2 Cross-Platform!’; // sm-crypto加密默认输出为16进制字符串。我们需要将其转换为Base64以便网络传输。 const encryptedHex sm2.doEncrypt(plainText, publicKey); // 输出16进制C1C3C2 const encryptedDataForNet Buffer.from(encryptedHex, ‘hex’).toString(‘base64’); // 现在 encryptedDataForNet 可以发送给.Net后端用上面的DecryptFromSmCrypto方法解密。 // 场景二后端加密前端解密 // 假设从.Net后端收到了Base64编码的密文 encryptedDataFromNet const encryptedDataFromNet ‘BASE64_STRING_FROM_NET’; const encryptedHexForJs Buffer.from(encryptedDataFromNet, ‘base64’).toString(‘hex’); const decryptedText sm2.doDecrypt(encryptedHexForJs, privateKey); // 输入16进制C1C3C2 console.log(‘解密结果’, decryptedText);注意事项这里最关键的桥梁是Base64编码。sm-crypto的doEncrypt输出十六进制字符串而网络传输更适合文本型的Base64。因此前端需要将十六进制密文转换为Base64再发送后端收到Base64后解密前无需关心其原始十六进制顺序因为Base64解码后就是正确的字节顺序。反过来后端加密输出Base64前端需要将其Base64解码回十六进制字符串再调用doDecrypt。务必确保两端使用的Base64编解码库是标准且兼容的。JavaScript中推荐使用BufferNode.js或atob/btoa浏览器注意处理Unicode.Net端使用Convert.ToBase64String和Convert.FromBase64String。6. 核心环节三签名与验签的跨平台实现签名验签用于验证数据的完整性和来源同样需要严格对齐参数。6.1 签名在.Net中生成能被sm-crypto验证的签名/// summary /// 使用SM2签名数据签名结果兼容sm-crypto的verifySignature方法 /// /summary /// param nameprivateKeyHex私钥/param /// param namemessage待签名的消息/param /// param nameuid用户ID必须与验签方一致默认与sm-crypto一致/param /// returns签名结果的十六进制字符串R||S/returns public static string SignForSmCrypto(string privateKeyHex, string message, string uid 1234567812345678) { ECPrivateKeyParameters privKey LoadPrivateKeyFromHex(privateKeyHex); // 创建SM2签名器 SM2Signer signer new SM2Signer(); // 准备参数私钥、SM3摘要、用户ID ParametersWithID parameters new ParametersWithID(privKey, Encoding.UTF8.GetBytes(uid)); signer.Init(true, parameters); byte[] msgBytes Encoding.UTF8.GetBytes(message); signer.BlockUpdate(msgBytes, 0, msgBytes.Length); // 生成签名得到BigInteger数组 [R, S] BigInteger[] sig signer.GenerateSignature(); // 将R和S转换为固定长度的十六进制字符串然后拼接 string rHex sig[0].ToString(16).PadLeft(64, 0); string sHex sig[1].ToString(16).PadLeft(64, 0); return rHex sHex; // 128位十六进制字符串 }关键点ParametersWithID用于传入用户IDUID这个值必须与前端验签时使用的UID完全相同。sm-crypto的默认UID是‘1234567812345678’所以我们这里也默认使用它。签名输出是R和S的拼接各64位十六进制总共128位。6.2 验签在.Net中验证sm-crypto生成的签名/// summary /// 验证由sm-crypto的doSignature方法生成的签名 /// /summary /// param namepublicKeyHex公钥/param /// param namemessage原始消息/param /// param namesignatureHex签名128位十六进制字符串/param /// param nameuid用户ID必须与签名方一致/param /// returns验签是否通过/returns public static bool VerifyFromSmCrypto(string publicKeyHex, string message, string signatureHex, string uid 1234567812345678) { if (signatureHex.Length ! 128) throw new ArgumentException(Signature hex length must be 128.); ECPublicKeyParameters pubKey LoadPublicKeyFromHex(publicKeyHex); SM2Signer verifier new SM2Signer(); ParametersWithID parameters new ParametersWithID(pubKey, Encoding.UTF8.GetBytes(uid)); verifier.Init(false, parameters); byte[] msgBytes Encoding.UTF8.GetBytes(message); verifier.BlockUpdate(msgBytes, 0, msgBytes.Length); // 从签名十六进制字符串解析出R和S string rHex signatureHex.Substring(0, 64); string sHex signatureHex.Substring(64, 64); BigInteger r new BigInteger(rHex, 16); BigInteger s new BigInteger(sHex, 16); return verifier.VerifySignature(new BigInteger[] { r, s }); }6.3 前端sm-crypto的签名验签操作// 签名 const message ‘重要数据’; const uid ‘1234567812345678’; // 必须与后端一致 const signatureHex sm2.doSignature(message, privateKey, { hash: true, // 默认使用SM3哈希保持true uid: uid // 指定UID }); // signatureHex 是一个128位的十六进制字符串可以发送给后端验证。 // 验签 const publicKey ‘04xxxx...’; const signatureFromNet ‘后端传来的128位签名十六进制字符串’; const verifyResult sm2.doVerifySignature(message, signatureFromNet, publicKey, { hash: true, uid: uid // 同样必须指定相同的UID }); console.log(‘验签结果’, verifyResult);实操心得签名验签失败十有八九是UID对不上。一定要把UID作为一个重要的配置项来管理在前后端配置文件中明确指定同一个值。不要依赖任何一方的默认值最好在代码中显式传入。另外sm-crypto的doSignature和doVerifySignature的选项hash: true表示库内部会先对消息进行SM3哈希这与我们.Net端使用SM2Signer内部也集成了SM3的行为是一致的所以保持true即可。如果你传递的是已经哈希过的值才需要设置hash: false但这种场景较少。7. 常见问题排查与调试技巧实录即使按照上述步骤操作你可能还是会遇到各种“坑”。下面是我在实践中总结的一些常见问题及其解决方法。7.1 解密失败无效的密文长度或格式症状在.Net端调用ProcessBlock解密时抛出异常提示“invalid ciphertext”或“invalid length”。排查检查Base64编码确保传输的Base64字符串是完整的没有丢失填充符没有URL编码转换如变成 。可以在线找一个Base64解码工具验证你的Base64字符串是否能正确解码回二进制数据。验证密文顺序这是最可能的原因。用一个已知的、可工作的在线SM2工具分别用你的公钥加密一段文本然后用你的.Net解密代码和sm-crypto解密代码去解密这个在线工具生成的密文注意获取其Base64或十六进制格式。如果只有一方能解密说明你们的密文顺序不一致。你需要调整.NetSM2Engine的初始化模式或者手动对解密前的字节数组进行C1C2C3和C1C3C2的顺序转换。手动转换顺序示例如果库不支持直接设置// 假设 encryptedData 是原始字节数组顺序是C1C2C3需要转为C1C3C2 // C1固定为65字节未压缩点04XYC3SM3输出为32字节C2长度可变。 int c1Len 65; int c3Len 32; int c2Len encryptedData.Length - c1Len - c3Len; byte[] c1 new byte[c1Len]; byte[] c2 new byte[c2Len]; byte[] c3 new byte[c3Len]; Buffer.BlockCopy(encryptedData, 0, c1, 0, c1Len); Buffer.BlockCopy(encryptedData, c1Len, c2, 0, c2Len); Buffer.BlockCopy(encryptedData, c1Len c2Len, c3, 0, c3Len); // 重组为C1C3C2 byte[] reorderedData new byte[encryptedData.Length]; Buffer.BlockCopy(c1, 0, reorderedData, 0, c1Len); Buffer.BlockCopy(c3, 0, reorderedData, c1Len, c3Len); Buffer.BlockCopy(c2, 0, reorderedData, c1Len c3Len, c2Len); // 然后用 reorderedData 去解密7.2 验签失败签名无效症状验签始终返回false。排查首要检查UID百分之九十的问题出在这里。确保前端doSignature和doVerifySignature以及后端SignForSmCrypto和VerifyFromSmCrypto使用的UID字符串完全一致包括大小写和长度。建议在代码中定义一个常量。检查消息原文确保验签时使用的消息原文与签名时完全一致一个空格、一个换行符都不能差。特别是在网络传输后注意处理可能的空白符和编码问题。检查公钥验签使用的公钥必须与签名私钥对应。可以用这对密钥先试试加密解密确保密钥对本身是有效的。签名格式确认签名是128位的十六进制字符串R和S各64位。检查是否有0x前缀是否需要去除。7.3 性能与内存问题症状加密/解密大文件或高频调用时内存飙升或速度慢。建议SM2作为非对称加密不适合加密大量数据。通常做法是用SM2加密一个随机生成的对称密钥如SM4密钥然后用SM4去加密实际的大数据。即“SM2加密传输密钥SM4加密业务数据”的混合加密模式。在.Net端考虑对SM2Engine、SM2Signer等对象进行复用但要注意线程安全通常每个线程或每次操作创建新实例更简单安全。对于签名验签如果消息很长SM2Signer的BlockUpdate可以分批处理但通常一次性处理即可。7.4 跨平台编码的终极调试技巧搭建最小化测试用例创建一个最简单的控制台程序和一个最简单的Node.js脚本硬编码一对密钥、一个消息分别执行加密-解密、签名-验签的完整循环。先确保在这个隔离环境下能通。善用控制台输出在每个关键步骤如加载密钥后、加密后、转换Base64后将关键数据如密钥的字节长度、密文的十六进制前20位打印出来与另一端的日志进行比对。借助已知正确的第三方用同一个在线SM2工具分别生成用你的公钥加密的密文和用你的私钥签名的签名。然后用你的.Net代码和JS代码分别去解密、验签。这样可以快速定位是哪一端的实现有问题。