背景

单位最近的业务基本都迁移到招行的薪福通系统上了,但是还是有一些特殊需求要实现,需要对接薪福通的API。

银行的api加密方式基本都是固定的国密算法,招行薪福通使用了其中的sm2、sm3和sm4,关于加密算法简单说明一下:

SM2是国家密码管理局于2010年12月17日发布的椭圆曲线公钥密码算法,基于ECC。其签名速度与秘钥生成速度都快于RSA,非对称加密,该算法已公开

SM3是与SM2一样是由国家密码管理局于2010年12月17日发布的。SM3主要用数字签名及验证、消息认证码生成及验证、随机数生成等,其安全性及效率与SHA-256相当。可以用MD5作为对比理解。校验结果为256位,不可逆,该算法已公开。

SM4是国家密码管理局于2012年3月21日发布。对称加密,密钥长度和分组长度均为128位。

其他语言如 java 或者 c# 都有比较程序的密码库支持 sm2、sm3、sm4 加密,而且银行的 sdk 示例基本也只有这两种语言的版本。

百度一番发现 php 也有一位大神开源了一个库:https://github.com/lpilp/phpsm2sm3sm4。本文就基于这个库进行开发。

安装

composer require lpilp/guomi

请确保你升级到 composer 2 及以上版本。PHP >=7.2并且打开 gmp 组件支持。

我的 PHP 版本是 8.2.9,安装过程中提示:nette/schema 依赖错误,版本不支持 8.2.9。解决办法是修改composer.lock 文件,将依赖的 nette/schema 版本改为较新的支持 php 8.2 的版本,我这里改成 nette/schema v1.3.0 版本后,再次执行 composer 成功安装。

签名

招行 API 中分别用到了 sm2、sm3、sm4 三种加密算法,其中 sm3、sm4 比较简单,参考 phpsm2sm3sm4 的示例写就可以了。

本文重点讲一下 sm2,我被卡了两天。

按照招行文档的说明,需要采用 SM2 算法对请求类型+请求路径+请求体 body +请求时间戳的字符串加签。

先生成签名字符串signStr,拼接规则为: “POST ” + path + “n” + “x-alb-digest: ” + body字符串 + “n” + “x-alb-timestamp: ” + x-alb-timestamp对应的值。

https://xft.cmbchina.com/open/#/doc/open-document?id=10692&mid=10684

这里需要注意的是,因为拼接的文档里包含换行符,因此拼接的时候换行符要用双引号,而不能是单引号。

接下来是对签名字符串进行加签操作,招行文档说的是sm2withsm3,我找了半天也没找到扩展包里有对应的方法,后来发现实际就是sm2加密。

//需要加签的字符串拼接
$signStr = "POST " . $url . "?" . $this->flatten($config) . "n" ."x-alb-digest: ". $body . "n" . "x-alb-timestamp: " . $timestampS;
//通过sm2算法加签,第二个参数false表示每次随机生成签名。
$sm2 = new RtSm2('base64',false);
$apiSign = $sm2->doSign($signStr,config('xft.pro.authoritySecret'),'1234567812345678');
//打印签名
echo $apiSign;

打印的签名类似:

MEQCIA7kwAnoDZFLaqn1MknKHMEUFG7s5CCTTxdPAfyXLIM1AiB7yt0HTKANsY+H5jgP/gxC+aIYfWUxPe01jtO8kqzwew==

拿到招行验签网站(https://xft.cmbchina.com/open/#/simulate-tool)验签一致提示签名失败。

关键是招行的验签网站也没说明签名是个什么格式的数据,提供的 java sdk 还是编译过的没有源代码,后来发现 c# 的官方 sdk 是有源码版的,遂下载 c# 的 sdk 跑了一下,发现 c# 的签名是一个固定128位的字符串。

回到phpsm2sm3sm4提了个issues,作者回复的很快,原来sm2加密方法缺省返回是asn1(r,s)格式的base64字符串,而招行的签名只是 r+s的字符串组合,而作者也封装了相应的转换函数在src/util/SmSignFormatRS.php文件。

因此对得到的签名进行一下格式转换即可:

//需要加签的字符串拼接
$signStr = "POST " . $url . "?" . $this->flatten($config) . "n" ."x-alb-digest: ". $body . "n" . "x-alb-timestamp: " . $timestampS;
//通过sm2算法加签,第二个参数false表示每次随机生成签名。
$sm2 = new RtSm2('base64',false);
$apiSign = $sm2->doSign($signStr,config('xft.pro.authoritySecret'),'1234567812345678');
//对签名进行格式转换
$apiSign = bin2hex(base64_decode(SmSignFormatRS::asn1_to_rs($apiSign)));
//打印签名
echo $apiSign;

这样得到的签名类似:

4d510e0a696d3f679b3ff576502686d93c8f2ccc68d4aa54f5911359750520434fd107edd4535230d776ca0602134a08b4de607a618a648c8b048bb68dba4e0e

再次验签顺利通过。

附 PHP(Laravel)对接招行薪福通、实现 sm2、sm3、sm4加密的示例代码,以全量获取组织列表API为例:https://xft.cmbchina.com/open/#/doc/open-document?id=10692&mid=11616

$microTime = microtime(true);
$timestampMs = round($microTime * 1000);//当前时间戳,毫秒
$timestampS = floor($microTime);//当前时间戳,秒

$body = '{}';
$key = substr(config('xft.pro.authoritySecret'),0,32);
$arr['secretMsg'] = $this->sm4(hex2bin($key),$body);
$body = json_encode($arr);//此处返回的是加密的body密文,与招行模拟器计算结果相同
//dd($body);
$sm3 = new RtSm3();
$xAlbDigest = $sm3->digest($body);//此处返回的是对加密后的body内容按照SM3算法进行摘要签名,与招行模拟器计算记过相同。

$domain = 'https://api.cmbchina.com';
$url = '/ORG/orgqry/common/OPORGQRA';
$config = [
    'CSCAPPUID' => config('xft.pro.appid'), //APPID
    'CSCPRJCOD' => 'XFV12345', //企业ID
    'CSCUSRNBR' => 'A0001',
    'CSCUSRUID' => 'AUTO0001',
    'CSCREQTIM' => $timestampMs
];
$signStr = "POST " . $url . "?" . $this->flatten($config) . "n" ."x-alb-digest: ". $body . "n" . "x-alb-timestamp: " . $timestampS;
$sm2 = new RtSm2('base64',false);
$apiSign = $sm2->doSign($signStr,config('xft.pro.authoritySecret'),'1234567812345678');
$apiSign = bin2hex(base64_decode(SmSignFormatRS::asn1_to_rs($apiSign)));

$response = Http::withHeaders([
    'appid' => config('xft.pro.appid'),
    'x-alb-timestamp' => $timestampS,
    'x-alb-verify'=>"sm3withsm2",
    'x-alb-digest' => $xAlbDigest,
    'apisign' => $apiSign,
    'KeepAlive' => false,
    'UserAgent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.95 Safari/537.11',
])
->withBody($body,'application/json')
->post($domain . $url . "?" . $this->flatten($config));


$sm4 = new RtSm4(hex2bin($key));
$responseBody = $sm4->decrypt($response->body(),'sm4-ecb');
dd(json_decode($responseBody));