首页 / 资讯中心 / 文章详情

医保支付国密改造:SM2签名验签与SM4加解密DLL封装实践

医保支付国密改造:SM2签名验签与SM4加解密DLL封装实践 ★ FEATURED ARTICLE
上个月接到一个医保移动支付国密改造的任务简单说就是医保平台对接方要求我们提供一套Windows DLL里面封装SM2签名验签和SM4加解密供外部渠道系统调用。第一反应是不难原以为只是把openssl命令换成SDK结果真正动手才发现最费时间的不是算法而是DLL的导出约定、跨语言调用、内存释放、异常排除这一堆事。这篇就记录我从需求拆解、接口设计、代码封装到踩坑修复的完整过程给正在做同类国密改造或封装DLL的同学当个参考。1. 项目背景与需求拆解1.1 为什么医保移动支付要选SM2 SM4医保移动支付跟普通互联网支付最大的区别是个人信息敏感身份证号、医保卡号、手机号、就诊信息全都在报文里。这些字段如果走明文别说评测过不过真出一次数据泄漏就是重大事故。所以医保平台在商用密码应用安全性评估的要求下明确提出敏感数据要使用国密算法加密传输、交易报文要做签名验签。这里的算法搭配是有讲究的SM4是分组对称密码密钥长度128位用来加密实际业务数据。它最大的优势是加解密速度快适合手机号、证件号这种批量字段也适合整个报文体加密。SM2是椭圆曲线公钥密码算法用来做签名验签和密钥协商。把SM4加密后的密文或报文摘要拿SM2私钥签名接收方用公钥验证就能确认“这个报文确实是那个人发的中途没人改过”。简单说SM2负责“信任”与“防篡改”SM4负责“保密”。两者组合是国密改造里最常见的姿势。如果你在对接医保、商保或政务类接口这个套路基本是标配。1.2 为什么必须做成DLL而不是直接给源码或Java包业务方给我提的需求很明确需要提供一个Windows环境的DLL。我一开始还想发一个Jar包过去但对方渠道系统是C#、Node.js、C什么语言都有有的甚至是只有基础环境的Linux前置机根本不可能统一塞一个Java运行环境。DLL是Windows上最通用的动态库形态配合C风格导出接口几乎所有语言都能通过FFI或P/Invoke调起来。用DLL还有两个隐性好处保护算法实现细节医保平台对接涉及密钥体系和签名逻辑交付二进制DLL比交付源码更安全至少不会让对接方直接看到你的底层随机数处理和密钥存储逻辑。可以单独授权和升级算法策略、密钥管理逻辑如果变了只需要替换DLL不需要修改调用方代码。如果直接发源码后面想升级会很被动。1.3 标题里隐藏的完整业务链路再看一遍“国家医保移动支付国密算法SM2签名验签、SM4加解密DLL”它其实不是一个单纯算法Demo而是一个完整的密码服务模块。至少包含几层密钥生成SM2公私钥对的生成。SM2签名与验签对报文摘要或指定字段做签名供对方验签同时也要支持对对方报文的验签。SM4加解密用约定好的对称密钥对数据做CBC模式加解密。数据编码医保报文比较多的是字符串和JSON接口里要约定好输入输出是HEX还是Base64避免调用方在编码上各自发挥。你要交付的不只是“能跑的加密函数”而是一套对方能直接对接的规范。这就是标题浓缩的完整含义。2. 核心算法与接口设计2.1 SM2签名验签的算法要点SM2用的是国密局定义的sm2p256v1椭圆曲线密钥长度256位安全强度对标RSA-3072左右。签名过程和RSA签名的最大区别是SM2签名不但要看你签的原文还要带上一个“用户ID”参与Z值计算。Z值如果算错签名和验签必然失败。在每次签名时需要按固定的顺序计算Z值Z SM3(ENTLA || IDA || a || b || xG || yG || xA || yA)ENTLA是用户ID的比特长度占两个字节IDA是用户ID标准默认值是字符串“1234567812345678”但医保平台极有可能自定义所以DLL接口里必须把userId作为参数暴露出来不能写死。签名结果一般有两种格式一种是DER编码类似纯数字签名场景里的“30 xx ...”另一种就是SM2标准里常见的r || s两个256位大整数各32字节拼成64字节。很多平台接口文档会明确要求固定64字节紧凑格式也有要求ASN.1 DER的。封装DLL时一定先确认对方的验签格式否则算法全对也验不过。2.2 SM4加解密的算法要点SM4分组长度128位密钥长度128位听起来跟AES-128很像但它S盒、轮常数都不一样网上那些把SM4当AES用的代码基本都会踩坑。实际医保项目里用得最多的是CBC模式加PKCS7填充。CBC模式需要16字节的IV加同一个IV会导致相同明文加密成相同密文这在业务上容易被做频率分析所以IV不能是一个固定值。常见做法是每次加密随机生成IV然后拼在密文头部解密时先取出IV再解密。我在这次DLL封装里也采用了这个方案。SM4一次的输入长度没有限制但CBC模式要求每块16字节最后一块要用PKCS7填充。C里OpenSSL的EVP接口默认不带填充需要手动设置。如果调用方给的明文长度不是16的倍数服务端解密时会出现“解密失败”或者末尾多了不可见字符。2.3 DLL导出接口与参数约定我在设计接口时一直遵循一个原则调用方分配内存DLL只往里面填数据。这样能避开C跨模块内存释放崩溃也就是经常遇到的HEAP_CORRUPTION。每个函数先传NULL进去返回需要的长度再分配好缓冲区调用第二次。最终导出的核心函数如下#ifdef __cplusplus extern C { #endif /* * 生成SM2密钥对 * outPub: 输出公钥非压缩格式04 || x || y共65字节 * outPriv: 输出私钥32字节二进制 * pubLen / privLen: 传入缓冲区大小返回实际大小 * 返回0成功非0为错误码 */ __declspec(dllexport) int SM2_GenerateKeyPair( unsigned char* outPub, int* pubLen, unsigned char* outPriv, int* privLen ); /* * SM2签名 * userId: 用户ID * data: 待签名数据原文 * privKey: 签名私钥 * outSign: 签名输出r || s 固定64字节 */ __declspec(dllexport) int SM2_Sign( const unsigned char* userId, int uidLen, const unsigned char* data, int dataLen, const unsigned char* privKey, int privKeyLen, unsigned char* outSign, int* signLen ); /* * SM2验签 */ __declspec(dllexport) int SM2_Verify( const unsigned char* userId, int uidLen, const unsigned char* data, int dataLen, const unsigned char* pubKey, int pubKeyLen, const unsigned char* sign, int signLen ); /* * SM4-CBC加密自动生成IV并拼在密文前16字节 * key为16字节ivOut为16字节 */ __declspec(dllexport) int SM4_CBC_Encrypt( const unsigned char* key, int keyLen, const unsigned char* in, int inLen, unsigned char* out, int* outLen, unsigned char* ivOut, int* ivOutLen ); /* * SM4-CBC解密从密文前取IV */ __declspec(dllexport) int SM4_CBC_Decrypt( const unsigned char* key, int keyLen, const unsigned char* in, int inLen, unsigned char* out, int* outLen ); #ifdef __cplusplus } #endif这个接口设计有几个细节统一用二进制字节数组不掺和HEX、Base64。编码转换放在调用方处理DLL只干算法减少一层歧义。错误码不能只返回0和1至少要有这几个密钥长度错误、内存不足、填充错误、验签失败、参数为空。命令要用extern C并且明确__stdcall还是__cdecl。不同调用约定直接影响Java JNA和C# P/Invoke的传参方式测试时最容易翻车。提示如果你最终要交付给多个平台最好在DLL里再写一个VersionInfo导出函数返回DLL版本和算法兼容信息。我在对接时曾因为对方拿着旧版DLL测试新版签名排查了一个下午才定位到后来加了版本函数就再没过这种蠢事。3. 基于开源密码库实现DLL的完整实操3.1 密码库选型OpenSSL、GmSSL还是BouncyCastle封装国密算法绕不开底层密码库。我的选型是OpenSSL主要原因有几个:OpenSSL 1.1.1之后官方加入了SM2、SM3、SM4支持不需要找第三方补丁。很多存量服务已经安装了OpenSSL直接用EVP接口写起来不费劲。GmSSL虽然国密支持更好但API和社区资料相对少团队后来不熟悉。如果是C#写的DLL那就不用纠结直接引BouncyCastle NuGet包它对SM2、SM4、SM3支持比较完整。Java侧其实也类似BC包成熟。如果你决定用OpenSSL建议用1.1.1或3.x不要用1.0.2。1.0.2默认没有SM2的EVP封装自己手动走EC_键流程会很痛苦。3.2 Windows下VS工程环境配置我用Visual Studio 2019建了一个空的DLL工程然后通过vcpkg装了OpenSSLvcpkg install openssl:x64-windows装好之后用vcpkg integrate install把路径注入到VS工程属性里就自动有了include和lib引用。注意三点64位DLL必须配64位OpenSSL32位DLL必须配32位OpenSSL混着用必然导致DLL加载报0x0000007E。运行时库要保持一致。如果调用方用的MD动态CRTDLL工程里也尽量用MD避免多个CRT实例引发内存释放问题。发布时要记得把libcrypto-3.dll或libssl-3.dll放在DLL同目录。很多“DLL已经复制过去了还是初始化失败”的问题其实是因为OpenSSL的依赖库没带全。3.3 SM2签名验签的OpenSSL实现OpenSSL实现SM2签名时我是用EVP方式封装的。核心逻辑如下#include openssl/evp.h #include openssl/ec.h #include openssl/sm3.h #include openssl/pem.h int SM2_Sign(const unsigned char* userId, int uidLen, const unsigned char* data, int dataLen, const unsigned char* privKey, int privKeyLen, unsigned char* outSign, int* signLen) { EVP_PKEY* pkey NULL; EVP_MD_CTX* mctx NULL; EC_KEY* ec_key NULL; const EC_GROUP* group NULL; int ret -1; // 用裸私钥构造 EC_KEY ec_key EC_KEY_new_by_curve_name(NID_sm2); if (!ec_key) goto cleanup; group EC_KEY_get0_group(ec_key); BIGNUM* d BN_bin2bn(privKey, privKeyLen, NULL); if (!d) goto cleanup; EC_KEY_set_private_key(ec_key, d); BN_free(d); // 根据私钥推导公钥 EC_POINT* pub EC_POINT_new(group); EC_POINT_mul(group, pub, EC_KEY_get0_private_key(ec_key), NULL, NULL, NULL); EC_KEY_set_public_key(ec_key, pub); EC_POINT_free(pub); // 创建EVp密钥 pkey EVP_PKEY_new(); EVP_PKEY_assign_EC_KEY(pkey, ec_key); ec_key NULL; // 所有权转移 mctx EVP_MD_CTX_new(); EVP_PKEY_CTX* pctx NULL; if (EVP_DigestSignInit(mctx, pctx, EVP_sm3(), NULL, pkey) 0) goto cleanup; // 设置SM2用户ID这一步千万不能漏 if (EVP_PKEY_CTX_set1_id(pctx, userId, uidLen) 0) goto cleanup; if (EVP_DigestSignUpdate(mctx, data, dataLen) 0) goto cleanup; size_t sigLen *signLen; if (EVP_DigestSignFinal(mctx, outSign, sigLen) 0) goto cleanup; *signLen (int)sigLen; ret 0; cleanup: EVP_MD_CTX_free(mctx); EVP_PKEY_free(pkey); EC_KEY_free(ec_key); return ret; }这段代码是可行思路不是全量可编译代码但核心点都在构造EC_KEY时曲线名称要用NID_sm2如果误用NID_X9_62_prime256v1签名结果跟国密平台对不上。用户ID必须是UTF-8字节流不能是宽字符。这里签名结果默认是DER格式如果你接口约定64字节r||s需要用ECDSA_SIG解析DER后手工拼接。SM2验签逻辑类似只是把EVP_DigestSignInit换成EVP_DigestVerifyInit调用EVP_DigestVerifyFinal。唯一要额外注意的是从公钥字符串构造EC_KEY时要处理04开头的非压缩公钥格式不要忘了前置的固定头。3.4 SM4加解密的OpenSSL实现SM4加解密代码比SM2简单不少核心是EVP接口#include openssl/evp.h #include openssl/rand.h int SM4_CBC_Encrypt(const unsigned char* key, int keyLen, const unsigned char* in, int inLen, unsigned char* out, int* outLen, unsigned char* ivOut, int* ivOutLen) { EVP_CIPHER_CTX* ctx NULL; int ret -1; int len1 0, len2 0; if (keyLen ! 16) return -2; ctx EVP_CIPHER_CTX_new(); unsigned char iv[16]; RAND_bytes(iv, sizeof(iv)); // 加密后的IV拼在输出最前面方便解密时取 memcpy(out, iv, 16); unsigned char* encOut out 16; if (EVP_EncryptInit_ex(ctx, EVP_sm4_cbc(), NULL, key, iv) ! 1) goto cleanup; if (EVP_CIPHER_CTX_set_padding(ctx, 1) ! 1) // 启用PKCS7填充 goto cleanup; if (EVP_EncryptUpdate(ctx, encOut, len1, in, inLen) ! 1) goto cleanup; if (EVP_EncryptFinal_ex(ctx, encOut len1, len2) ! 1) goto cleanup; *outLen 16 len1 len2; memcpy(ivOut, iv, 16); *ivOutLen 16; ret 0; cleanup: EVP_CIPHER_CTX_free(ctx); return ret; }这段代码里有个特别容易忽略的点输出缓冲区长度必须是inLen 16 16。因为PKCS7填充最多增加16字节再加上前面拼接的16字节IV。如果调用方只按明文长度分配缓冲区调用就会内存越界。我后来在接口文档里直接写了“out缓冲区必须 inLen 32”建议你也这么做。3.5 导出、编译与调试细节C编译DLL时除了前面提过的extern C还建议用.def文件来精确控制导出符号而不是全靠__declspec(dllexport)。.def文件写法LIBRARY sm_utils EXPORTS SM2_GenerateKeyPair SM2_Sign SM2_Verify SM4_CBC_Encrypt SM4_CBC_Decrypt VersionInfo这样生成的DLL在依赖工具里看到的符号清晰不会带一堆C name mangling后缀。调试DLL时我建议直接用Visual Studio的“调试-附加到进程”不行的话写一个小小的EXE调用DLL进行单步调试。不要直接丢给Java去调否则你定位问题的链路太长。注意如果使用C#封装DLL再RegAsm注册实际上“RegAsm注册失败”多数是因为目标框架位数不匹配或者类没有标记为ComVisible。不过我更推荐走纯C DLL P/Invoke/JNA的方式少一层COM注册和权限问题。4. 测试、问题排查与集成落地4.1 用OpenSSL命令行做算法自检DLL写完后我先用OpenSSL命令行生成一组SM2密钥和SM4密文作为联调用“标准答案”。# 生成SM2私钥 openssl ecparam -name SM2 -genkey -out sm2_priv.pem # 从私钥导出公钥 openssl ec -in sm2_priv.pem -pubout -out sm2_pub.pem # 签名指定用户ID echo -n coordination data | \ openssl dgst -sm3 -sign sm2_priv.pem \ -id 1234567812345678 -out sign.bin # 验签 echo -n coordination data | \ openssl dgst -sm3 -verify sm2_pub.pem \ -signature sign.bin -id 1234567812345678这里一定要把命令行的-id参数和DLL里传的userId保持一致。我遇到过好几次“验签不通过”最后发现命令行用的是平台默认用户IDDLL里传了自定义ID结果当然对不上。SM4自测可以用openssl enc命令# CBC模式下使用PKCS7填充 openssl enc -sm4-cbc -K 0123456789abcdeffedcba9876543210 \ -iv 0102030405060708090a0b0c0d0e0f10 -in plain.txt -out cipher.bin用命令行跑通一组标准值后再在DLL里输入同样的key、iv、明文对比密文。如果密文不一致优先检查填充逻辑和IV字节序。4.2 编译DLL后最常见的加载失败问题这段时间我集中遇到了好几类报错也是热词里反复出现的错误现象大概率原因解决方案DLL load failed while importingPython或Java加载时初始化失败依赖的VC运行库或OpenSSL DLL缺失安装VC Redistributable把exe同目录补齐所有依赖库WinError 1114 动态链接库初始化例程失败DllMain里初始化逻辑抛异常或者依赖库版本冲突精简DllMain不要在里面做复杂初始化用Dependency Walker检查依赖regasm注册成功但调用时找不到类目标平台x86/x64不一致用管理员权限RegAsm并指定/codebase调用约定不一致导致栈错误没有统一__stdcall或__cdeclP/Invoke和JNA的调用约定必须与DLL一致内存崩溃跨模块new/delete或malloc/free混用坚持调用方分配缓冲区策略有一个排查技巧在DLL里加一个极简函数比如int DllDebug() { return 42; }先让Java/C#调用这个函数确认DLL加载和调用链没问题再去调算法函数。这个函数能帮你把“DLL没加载”和“算法函数有问题”两类错误快速分开。4.3 Java通过JNA调用DLL的落地姿势医保移动支付的后端很多是JavaDLL交付后大概率是Java用JNA去调。我在项目中给对接方提供了一份简单的Java调用示例import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.ptr.IntByReference; import com.sun.jna.ptr.PointerByReference; public interface SmUtils extends Library { SmUtils INSTANCE Native.load(sm_utils, SmUtils.class); int SM2_GenerateKeyPair(byte[] outPub, IntByReference pubLen, byte[] outPriv, IntByReference privLen); int SM2_Sign(byte[] userId, int uidLen, byte[] data, int dataLen, byte[] privKey, int privKeyLen, byte[] outSign, IntByReference signLen); int SM2_Verify(byte[] userId, int uidLen, byte[] data, int dataLen, byte[] pubKey, int pubKeyLen, byte[] sign, int signLen); int SM4_CBC_Encrypt(byte[] key, int keyLen, byte[] in, int inLen, byte[] out, IntByReference outLen, byte[] ivOut, IntByReference ivOutLen); int SM4_CBC_Decrypt(byte[] key, int keyLen, byte[] in, int inLen, byte[] out, IntByReference outLen); }注意JNA里默认是C声明也就是cdecl如果你DLL函数用了__stdcall要在接口里继承StdCallLibrary而不是Library。JNA调用在Windows 64位上其实cdecl和stdcall区别不大但32位上这是个大坑最好还是统一。4.4 数据库国密测试怎么做热搜词里有个“sm2如何做数据库国密测试”“oceanbase国密sm4”之类的。带国密算法的DLL如果要跟数据库字段加密配合常见场景是数据库里存的是SM4加密后的密文应用层把明文加密后入库查询时再解密。测试的方法其实不复杂在数据库里新建一个表敏感字段类型设为VARBINARY或BLOB不要用VARCHAR存二进制否则会出现截断或乱码。先用DLL加密一条固定的明文“13800001234”得到密文直接SQL插入库。再从库里把密文取出来用DLL解密比对原文。数据库侧的国密测试一般是验证“同一条明文在两次加密中密文不同”因为IV随机以及“密文长度是否符合分组规则”。我建议在数据库字段加密时把IV也存下来不要只存密文。否则解密时如果没有随机IV信息会非常被动。我当时是直接把IV拼在密文头数据库字段语义就是“IV || Cipher”。5. 安全规范与性能优化经验5.1 密钥管理与证书链路国密改造里最容易被忽略的不是算法本身而是密钥怎么来、怎么存。医保移动支付项目里SM2密钥对通常和平台侧证书绑定而不是自己随便生成一个私钥就能联调。正式环境需要做证书请求由平台签发证书私钥要在加密机或者KMS里保存。我在本次交付时DLL只负责“拿密钥做运算”不负责“持久化私钥”。也就是说DLL接口不接受文件路径只接受内存中的密钥字节。这样避免把私钥落到磁盘明文文件里被拖走就麻烦了。如果你的项目对安全性要求高建议给DLL加一层“内存锁定”处理使用OpenSSL函数把密钥BIGNUM锁在内存避免交换到磁盘。使用完立即清零不要等垃圾回收或析构。5.2 SM4性能优化与线程安全医保渠道系统并发量不算极低尤其是高峰期SM4加解密会被频繁调用。我压测后发现第一次调用会比后续调用慢很多原因主要是EVP上下文初始化和随机种子初始化。优化办法很简单对于SM4把EVP_CIPHER_CTX对象的创建放到一个池里复用不要每次加解密都新建销毁。对于SM2签名运算成本高于SM4但如果每次签名都需要从字节数组构造EC_KEY也会有不少开销。可以考虑在服务端缓存EC_KEY对象只做一次构造。线程安全OpenSSL 1.1.1以后默认线程安全已经做得比较好但多个线程同时使用同一个EVP_PKEY对象签名会互踩需要用锁或每个线程一个上下文。我在交付文档里专门给调用方写了一条建议“DLL内部不做全局状态线程安全由调用方控制建议每线程一个上下文。”这样实现简单也不容易出并发问题。5.3 异常处理与日志设计很多人封装DLL时常忽略异常捕获导致调用方在Java侧看到本机崩溃。更稳妥的做法是DLL顶层函数用C异常捕获包一层防止未捕获异常跨模块传播。int SafeSM2_Sign(...) { try { return SM2_Sign(...); } catch (...) { return ERR_UNEXPECTED; } }同时可以提供一个取错误信息的接口__declspec(dllexport) const char* SM2_GetLastErrorText();业务接入时一旦发现返回错误码不是0直接调用这个接口看原因。我因为这个设计省了很多沟通成本对方不再拿着“返回-1”问我是什么问题。结尾回头看一下这个项目真正让我有收获的不是SM2或SM4算法本身而是把一个算法合理封装成DLL的过程。算法有标准平台也有标准但内存释放、调用约定、用户ID、IV传递、线程安全这些坑才是真正需要经验的地方。这里再分享一个小技巧交付前一定要写一个“跨语言冒烟测试”用Java、C#、Python各调一遍DLL里的每个导出函数。我这边就曾经因为Java JNA默认cdecl和C# P/Invoke默认stdcall不一致导致C#侧一调用就栈溢出而Java侧完全正常。那种问题看代码看半天看不出来只有实测才能暴露。这个习惯我一直留到现在哪怕是再简单的DLL也要这么过一遍。
阅读完成 · 觉得有帮助?
咨询建站