上个月跑招行收款通的国密改造邮件里给了一对SM2密钥文档加密算法一栏写着SM2。我第一反应是翻PHP手册找找有没有sm2_encrypt这种函数答案自然是没有。紧接着试了openssl_public_encrypt直接报参数错误。折腾到凌晨才意识到SM2和RSA完全是两回事——不只是算法变了密钥格式、密文编码、签名摘要的拼接方式全都换了一套规则。这篇文章就把我踩过的坑和最终跑通的流程完整写出来给同样在对接招行收款通的PHP开发者做一个实战参考。文章适合以下几类人看正在联调招行收款通国密接口的PHP开发、准备把RSA代码迁移到SM2的老项目维护者以及纯粹想搞懂SM2在PHP里怎么落地的人。我会从密钥分工讲起从工具选型到加解密签名验签再到联调排错一步一步拆开讲。1. 收款通对接里SM2到底管着哪些加密与签名动作1.1 国密改造前后的最大变化之前很多银行接口走的是RSA商户拿一对RSA密钥签名用私钥验签用公钥加密反而用得少。国密改造之后RSA被换成了SM2这一换不仅仅是算法名变了。招行收款通的文档里会给出一整套国密材料通常包括商户私钥、商户公钥、招行公钥偶尔还会有证书文件。这套材料里每一样都不能少更不能搞混。我见过不少人拿到密钥后第一件事就是往openssl_public_encrypt里塞其实方向完全错了。SM2在收款通对接里承担两类任务一类是加密/解密保护报文体中的敏感字段另一类是签名/验签证明报文确实来自声称的商户。这两类任务用的是不同的密钥方向混用必挂。1.2 四把钥匙的分工谁加密、谁签名先把这个最关键的事情说清楚。我们手里实际掌握的密钥和招行侧掌握的密钥是对称的密钥谁持有用途商户私钥我方对请求报文签名证明报文来自我方商户公钥招行侧招行验证我方签名的凭据部分接口也用于回调时加密数据招行公钥我方我方加密请求报文中的body或敏感字段招行私钥招行侧招行解密我方报文、对回调和响应数据签名换句话说我方手里只有两样东西商户私钥、招行公钥。但这并不代表只能发不能收。招行发起回调时可以用商户公钥加密敏感数据我们用商户私钥解密招行也会用自己的私钥对回调签名我们用招行公钥验签。所以一个完整的对接流程里自己手里那两把钥匙就够用。1.3 招行报文里常见的加密和签名字段虽然招行不同版本的接口文档字段名可能有差异但报文结构通常长这样{ version: 1.0, charset: UTF-8, signType: SM2, encType: SM2, merId: 商户号, reqId: 唯一请求号, body: Base64(SM2加密后的业务JSON), sign: Base64(SM2签名结果) }我在这里用body泛指业务参数加密后的位置有的文档可能叫data、content这都不重要。重要的是把动作拆解清楚先构造明文业务JSON用招行公钥加密得到密文再做Base64然后再把需要参与签名的字段拼接成原文用商户私钥签名得到签名字符串。很多PHP开发者第一次联调失败都是在字段名上较劲而真正的问题出在“该加密的没加密、该签名的没签名”。注意具体字段名和签名拼接规则一律以你手上的招行最新接口文档为准。下面所有示例里的字段名只是按通用结构写的可替换占位符。2. PHP侧怎么选SM2实现openssl扩展帮不上忙纯PHP库是主路2.1 PHP的openssl扩展为什么不能直接干这事我理解大家的第一反应openssl扩展在PHP里干RSA、AES、签名验签都已经很熟了SM2应该也差不多。实际情况是OpenSSL 1.1.1底层确实加入了SM2、SM3、SM4的支持但PHP的openssl扩展暴露给业务层的函数非常有限。你想在PHP里直接调用类似openssl_public_encrypt去加密SM2数据是做不到的。更麻烦的是即使系统里的OpenSSL支持SM2PHP扩展也没有提供设置SM2用户标识UserID、密文点编码格式这些参数的入口。而招行报文对接恰恰需要精确控制这几个点。多数生产环境的OpenSSL版本还比较老直接拿openssl_verify去验SM2签名大概率会遇到不支持的报错。所以我的结论很明确PHP侧做SM2加解密和签名验签不要死磕openssl扩展老老实实找纯PHP实现或者C扩展绑定。2.2 三条可行路线怎么选当时我列了几个可选方案实测后画了一张对比表方案优点缺点适合场景纯PHP库依赖bcmath/gmp跨平台、composer安装、不依赖服务器openssl版本性能一般库质量参差不齐大多数业务系统PHP-GmSSL或类似C扩展性能好底层是国密标准库编译安装繁琐线上环境不一定允许多环境维护成本高高并发、性能敏感OpenSSL 3.0 Provider PHP FFI直接调用系统算法对OpenSSL版本要求高代码复杂边界情况多不太建议从投入产出比看纯PHP库是首选。性能问题一会儿我会单独讲对大多数支付回调场景来说够用。如果日请求量到了十几万再考虑把加解密服务抽出去也不迟。2.3 我实际采用的纯PHP库和初始化代码我用的包是lizhichao/sm2纯PHP实现基于bcmath扩展支持SM2加解密、SM3摘要、签名验签也支持自定义UserID。安装很简单composer require lizhichao/sm2初始化代码大致是这样use SM2\SM2; $sm2 new SM2(); // 默认UserID是 1234567812345678如果招行文档指定了其他值必须在这里设置 $sm2-setId(1234567812345678); // 密钥要求是HEX字符串 $privateKeyHex 商户私钥的HEX十六进制字符串; $publicKeyHex 招行公钥的HEX十六进制字符串; // 加密 $ciphertext $sm2-doEncrypt(需要加密的明文, $publicKeyHex); // 解密 $plaintext $sm2-doDecrypt($ciphertext, $privateKeyHex); // 签名 $sign $sm2-doSign(待签名内容, $privateKeyHex); // 验签 $result $sm2-doVerify(待验签内容, $sign, $publicKeyHex);如果你在试的时候发现doSign、doVerify方法名对不上以你引入包的实际README为准方法名差异不影响整体流程。真正需要注意的是密钥如果是从招行下载的PEM或者Base64串要转换成这个库认识的HEX格式否则后面全是白搭。3. 密钥格式、密文顺序和Z值这三件事决定SM2联调成败3.1 私钥32字节、公钥65字节密钥格式先对齐SM2私钥就是一个256位的随机整数用十六进制表示就是32字节、64个字符。公钥则是椭圆曲线上的一个点未压缩格式是04 || X || YX和Y各32字节加起来65字节、130个十六进制字符。这里有个高频坑很多平台下发的公钥可能不带开头的04只给你64字节的X和Y拼接串。有些库内部会自动补04有些库不补。lizhichao/sm2这类库对公钥格式比较挑剔我在对接时干脆写了一个统一的格式化函数function normalizePublicKey(string $key): string { // 去掉复制时可能混进去的换行、空格 $key str_replace([\r, \n, ], , $key); // 64字节x坐标和y坐标拼接需要补04前缀 if (preg_match(/^[0-9a-fA-F]{128}$/, $key)) { return 04 . strtolower($key); } // 130字节已经是04开头的标准未压缩公钥直接返回 if (preg_match(/^[0-9a-fA-F]{130}$/, $key)) { return strtolower($key); } throw new InvalidArgumentException(无法识别的SM2公钥格式); }千万不要手工去改密钥串。直接从文件读取再用脚本统一格式化能省掉后面排查“公钥点无效”的半天时间。3.2 加密为什么每次结果都不一样SM2是概率加密。打个比方RSA加密同一句话结果固定SM2加密同一句话每次加密结果都不一样因为加密过程会生成一个随机数k。这就像一个人签合同每次盖章的位置、力度都略有不同但都能证明是同一个人签的。具体到代码流程SM2加密时会随机选一个k计算椭圆曲线点C1 kG再用对方的公钥算出共享点对共享点的坐标做KDF密钥派生得到一串密钥流明文跟这串密钥流异或得到C2最后用SM3对共享点坐标和明文做一个摘要得到C3。最终密文按国密标准组装为C1 || C3 || C2。这个特性带来的直接后果是联调时不要拿两次加密的密文做字节对比。我看到有人反复怀疑自己代码写错了就因为两次请求密文不一样其实这是正常的。3.3 解密靠什么还原共享点的秘密解密方手里有私钥能通过C1和自己的私钥重新算出同一个共享点然后用同一套KDF派生密钥流把C2异或回去还原明文。最后重算一遍SM3和密文里的C3对比如果一致说明密文没有被篡改过。所以私钥绝不能泄露。谁能拿到私钥谁就能解出所有发给你方的密文也能伪造你的签名。3.4 签名验签里的Z值以及默认UserID的坑SM2签名比加密更容易踩坑核心就在Z值上。SM2签名不是直接对原始报文做摘要而是先拼出一个叫Z值的东西Z SM3(ENTL_A || UserID || a || b || xG || yG || xA || yA)这里a、b是曲线参数G是基点xA、yA是签名者公钥坐标。ENTL_A是UserID的比特长度。算完Z后再用SM3(Z || 待签名数据)得到摘要e最后用e去做椭圆曲线签名运算。所以我们常说的SM2签名算法严格叫法是SM2withSM3。关键坑在于UserID。SM2标准文档里的默认UserID是1234567812345678很多纯PHP库内部也是按这个默认值写的。但招行接口在国密文档里可能指定了别的UserID例如商户号或空字符串一旦没对齐就会出现一个非常迷惑的报错——你自己本地签名验签一切正常招行却一直返回验签失败。我用lizhichao/sm2时会在初始化阶段强制设置一次UserID$sm2-setId(招行文档里指定的UserID);如果文档里没写才用默认值。这个细节直接决定Z值算得对不对。4. 对接实战请求报文、回调通知与密钥方向4.1 请求流程先加密body再对拼接值签名实际对接时建议把加解密和签名验签封装成一个类业务代码只关心明文和结果。我习惯这样组织class CmbPayCrypto { private SM2 $sm2; private string $merchantPrivateKeyHex; private string $cmbPublicKeyHex; public function __construct(string $merchantPrivateKeyHex, string $cmbPublicKeyHex, string $userId 1234567812345678) { $this-sm2 new SM2(); $this-sm2-setId($userId); $this-merchantPrivateKeyHex $merchantPrivateKeyHex; $this-cmbPublicKeyHex normalizePublicKey($cmbPublicKeyHex); } public function encrypt(string $plaintext): string { return $this-sm2-doEncrypt($plaintext, $this-cmbPublicKeyHex); } public function decrypt(string $ciphertext): string { return $this-sm2-doDecrypt($ciphertext, $this-merchantPrivateKeyHex); } public function sign(string $message): string { return $this-sm2-doSign($message, $this-merchantPrivateKeyHex); } public function verify(string $message, string $signature): bool { return $this-sm2-doVerify($message, $signature, $this-cmbPublicKeyHex); } }请求报文的组装流程大概是$crypto new CmbPayCrypto($merchantPrivateKeyHex, $cmbPublicKeyHex, $userId); // 1. 业务参数 $businessParams [ orderNo 202501010001, amount 100.00, noticeUrl https://your-domain.com/notify, ]; // 2. 转JSON注意保留中文不要转义成\uXXXX $bodyJson json_encode($businessParams, JSON_UNESCAPED_UNICODE); // 3. 用招行公钥加密body $cipherBody $crypto-encrypt($bodyJson); // 4. 按招行文档拼接签名原文 $signString merId{$merId}reqId{$reqId}body{$cipherBody}; // 5. 用商户私钥签名 $sign $crypto-sign($signString); // 6. 组装最终请求头 $request [ version 1.0, charset UTF-8, signType SM2, encType SM2, merId $merId, reqId $reqId, body $cipherBody, sign $sign, ];这里的核心动作是先把明文加密再拿已加密的body参与签名。签名原文里的body必须是加密后的密文串而不是明文JSON。这一点我在一开始写反了招行返回的验签失败把我折磨了很久。4.2 回调通知先验签再解密顺序不能反收到招行异步回调时处理顺序很重要。我的习惯是先验签验签通过后认为报文可信再解密body最后落库更新订单。$body $_POST[body] ?? ; $sign $_POST[sign] ?? ; $signString merId{$merId}reqId{$reqId}body{$body}; // 第一步用招行公钥验签 if (!$crypto-verify($signString, $sign)) { // 记录日志返回错误不让请求继续 throw new RuntimeException(回调验签失败); } // 第二步验签通过后再用商户私钥解密 $plainBody $crypto-decrypt($body); $businessData json_decode($plainBody, true, 512, JSON_THROW_ON_ERROR);先验签再解密本质上是“先确认来源可信再信任数据内容”。如果反过来等于先处理了一段可能来路不明的密文存在被伪造报文影响业务状态的风险。另外回调处理要保证幂等同一个reqId重复通知时不重复更新订单这也是联调时容易忽略的点。4.3 密钥方向为什么和直觉相反很多开发者第一次接触这类接口时会习惯于“我用对方公钥加密对方用自己私钥解密对方用我公钥加密我用自己私钥解密”。这个方向是对的但放到签名里就容易乱。我做了一张对照表对接时一眼就能看明白场景我方要做什么用哪把钥匙对方做什么我方发起请求加密body招行公钥招行私钥解密我方发起请求对报文签名商户私钥招行公钥验签招行发起回调对回调报文验签招行公钥招行私钥签名招行发起回调解密body商户私钥商户公钥加密简单记法加密永远用对方公钥解密永远用自己私钥签名永远用自己私钥验签永远用对方公钥。一旦发现代码里“用自己的公钥加密”“用对方私钥验签”那一定写错了。5. 联调排错实录五个让PHP开发者头疼的坑5.1 公钥前面少了04验签直接失败第一次和招行做联调时我从邮件里复制公钥粘贴到配置里顺手把开头的04当作“某种前缀”删了。结果一调用库直接抛not a valid point。排查了很久才发现是公钥格式问题。解决方式就是前面那个normalizePublicKey()函数。我的建议是无论公钥看起来像什么格式一律走一遍格式化函数再进入后续逻辑不要依赖“我肉眼看着没问题”。5.2 C1C3C2与C1C2C3密文顺序不对的解密乱码国标GB/T 32918里SM2密文的推荐排列是C1 || C3 || C2但很多开源库早期实现用的是C1 || C2 || C3直到现在仍有部分库默认保留旧顺序。如果招行按国标解析而你的库输出的是旧顺序招行解出来就是乱码或者直接报密文长度不对。对策是在选库阶段就确认清楚这个库输出的密文是C1C3C2还是C1C2C3是否支持切换lizhichao/sm2是按国标顺序输出的所以问题不大。如果你用的库输出C1C2C3又找不到切换开关就得自己把密文按坐标和摘要长度拆开重排成本会明显上升。这里有个经验联调前先拿招行文档里的“加密示例”或“测试向量”验证一次。如果文档给了预期密文而你本地加密结果对不上优先检查密文顺序而不是怀疑算法实现。5.3 Base64折行、URL编码和JSON转义三连坑SM2加密出来的密文是二进制转成Base64后不同工具处理方式不一样。有的库为了可读性会在每64个字符后面加一个换行符这个换行符一旦被拼进JSON或者通过表单POST传递就可能被转成空格或\n。收款通对接时我在测试环境遇到过一次“偶尔解密成功、偶尔解密失败”的诡异现象最后定位就是Base64里混入了换行。稳妥的做法是在发送前和接收后分别做一次清理function cleanBase64(string $str): string { return preg_replace(/\s/, , $str); }如果字段是放在URL参数里传递的还要注意urlencode和urldecode的顺序。我的建议是传输层统一用POST表单或JSON Body不要在QueryString里放大段Base64否则加号、斜杠、等号都会被URL编码搅浑。5.4 UserID不匹配导致的Z值验签失败这类问题最隐蔽。本地自己测签名验签通过发给招行返回“验签失败”招行返回的响应我方验签也失败。两个方向都不通但又说不出具体哪错了。后来把招行国密说明附件从头翻到尾才发现文档里指定了UserID不是默认值。SM2签名里UserID影响Z值Z值影响摘要ee错了整个签名验证必然失败。解决方式很简单初始化时显式设置$sm2-setId(文档指定的UserID);联调时如果验签失败第一反应不要去看曲线参数、不要重新推一遍公式先检查UserID有没有对齐。这是所有SM2对接里性价比最高的排查动作。5.5 Hex大小写、中文转义与签名原文不一致十六进制字符串的大小写也会造成问题。SM2库有的输出大写Hex有的输出小写Hex。如果招行按小写解析你给他大写签名串拼的内容看起来一样实际上字节不同验签照样失败。我现在的做法是所有密钥、密文、签名统一转小写Hex避免大小写混用。另一个非常常见的坑是中文转义。PHP里json_encode默认会把中文转成\uXXXX比如姓名变成\u59d3\u540d。如果你的签名原文用的是转义后的字符串而招行那边用的是转义前的明文两边签名自然对不上。处理方式是在做业务JSON时统一加参数$bodyJson json_encode($body, JSON_UNESCAPED_UNICODE);要不要转义完全取决于招行文档对签名原文的定义。关键是两条第一文档怎么写就怎么做第二加密和签名用的必须是同一个JSON字符串不要头尾分别生成两次。6. 上线前的性能、日志与密钥安全清单6.1 纯PHP的SM2性能量级我测下来是这样纯PHP实现SM2核心瓶颈是椭圆曲线点乘运算依赖bcmath或gmp做底层大整数运算。我在PHP 8.1的容器里简单压测过单次加密大约20到30毫秒单次签名大约5到10毫秒解密和加密量级接近。不同机器差异很大但这个量级对绝大多数收款通商户系统来说完全够用。需要注意的是如果你在请求处理链路里反复加解密同一份报文比如先解密、再验签、再加密回传一次请求可能累计消耗50毫秒以上。尽量精简次数能用一次解密就不要解两次能缓存公钥解析结果就不要每次初始化SM2对象。这里给个小建议把SM2对象做成单例或长生命周期对象避免每次请求都重新解析曲线参数。6.2 私钥放哪、日志怎么打才不会泄密私钥是核心资产绝不能写死在代码里更不能提交到Git仓库。我建议放到环境变量、配置中心或独立的密钥管理系统里运行时读取。日志方面尤其要注意不要把完整的密文、签名、私钥直接打出来。排查问题时可以打印前几十个字符用于比对而不是整段输出。一个简单的脱敏方法$logBody mb_substr($cipherBody, 0, 32) . ... . mb_substr($cipherBody, -8);另外如果招行侧能返回错误码和错误描述把这些结构化解出来记录好比打印一堆Hex字符串有价值得多。6.3 把加解密封装成独立类招行改版时不慌最后想强调一个工程上的建议无论用什么库都建议把SM2操作封装成独立类不要直接在业务代码里到处调用doEncrypt、doSign。封装层要接收密钥、UserID、密文格式这些配置项。将来招行调整文档字段、更换密钥、甚至把算法换成其他国密算法业务代码可以不改只需要动封装层和配置。我上面给的CmbPayCrypto类就是一个比较薄的封装实际项目中还可以再加上密钥缓存、日志埋点、耗时统计。这层多花半小时后面能省下好几个通宵。最后说一点个人体会。和招行联调那次我花时间最多的不是算法本身而是一个“看似不对但又查不出来”的密钥前缀问题。SM2和RSA最大的区别就是RSA出错通常会直接报错而SM2出错经常是数据能解出来、但验签不通过或者解出来是乱码因为你不知道是哪个环节的编码先歪了。所以对接前先把密钥格式、密文顺序、UserID、Base64编码这四件事和招行文档逐字核对一遍比什么都管用。希望这篇能帮正在联调的PHP同行少踩几个坑。
阅读完成 · 觉得有帮助?