最近在对接民生银行的电子账户接口,按照民生的要求,调用接口需要涉及 SM2 国密算法及 SSL 双向认证。目前银行端提供的只有 JAVA 版的 SDK,把 PHP 作为开发语言的我们表示很受伤。本文就针对涉及的两个点进行说明,简单汇总下 PHP 语言中的解决方案。


1、SM2 国密算法

针对接口请求参数的加密加签及响应数据的解密验签操作,民生银行的要求如下:

对数据进行 PKCS#7 带原文签名,并将签名结果加密成 CMS 格式的数字信封。
SM2 算法,签名所采用的 HASH 算法为 SM3(带 Z 值),加密所采用的算法为 SM4_CBCSM2 加密格式为老国密标准 C1||C2||C3

所谓的数字信封,是一种综合利用了对称加密技术和非对称加密技术两者的优点进行信息安全传输的一种技术。数字信封既发挥了对称加密算法速度快、安全性好的优点,又发挥了非对称加密算法密钥管理方便的优点。

之前对接的平安银行电子账户接口中,对接口的参数安全处理,也是类似上面数字信封的概念。但是采用的对称加密算法为 AESAES-256-CBC ),采用的非对称加密算法为 RSA22048 位秘钥,签名采用 SHA256 算法)。对于 AESRSA 算法的实现,在 PHP 中还是非常方便的,使用 OPENSSL 相关函数即可。

至于民生要求的 SM2 国密相关算法,其实在 PHP 中目前没有太好的方案。经过各种搜索,我找到了这个看起来似乎是目前最优的解决方案: GMSSLhttp://gmssl.org/docs/php-api.html )。

OpenSSL v1.1.1 新特性: 开始支持国密SM2/SM3/SM4加密算法(仅支持算法,未支持国密套件)
由于未对 OpenSSL 的的国密相关算法进行详细研究,暂不说明。

按照 GMSSL 的官方说明,首先进行编译安装,服务器系统是 CentOS ,基本很顺利就能安装完成。然后是重新编译 PHP ,按官方文档,需要用 GMSSL 中的文件替换 PHP 中的 ext/openssl ,然后再进行编译安装。整个编译过程报错很多,通过各种搜索和尝试,终于解决了。在这里不得不提一句,互联网上 GMSSLPHP 相关的资料真的很少,基本找不到可以参考的有价值信息,除了官网的那个 PHP API 的简单说明。

经过各种尝试,由于对 PKCS#7 的数字信封的格式要求实在不熟悉,另外参考了银行 SDK 中的 JAVA 代码的封装,仍然无解。出于时间的考虑,暂时放弃。后续有时间了还是要详细研究一下,我想更多是因为自己对这块的基础知识不足导致的。

由于上面的原因,最终我很不情愿的决定采用 PHP 调用 JAVA 代码的方式,通过民生的 SDK 来实现加密加签和解密验签操作。目前 PHP 调用 JAVA 有两种现有的解决方案,都是作者很多年前开发的,很久未更新了(也完全没有更新的必要)。一种是 PHP-Java-Bridge ( http://php-java-bridge.sourceforge.net/pjb/ ),另外一种是 LAJPhttp://code.google.com/p/lajp/ )。因为民生的 Demo 中提供的是 LAJP 的方案,我进行了测试可以通过,另外就是这个相对于 PHP-Java-Bridge 更加的轻量级,因此就采用了这个方案,对代码进行重构后整合进现有的项目中。


2、CURL SSL 双向认证

目前民生银行提供的接口对接方案有两种,即专线模式和互联网模式。专线模式的话就只需要对请求参数及响应数据做加密加签及解密验签即可,互联网模式的话还要额外进行 SSL 双向绑定。由于我们是采用互联网模式,因此就不得不来解决这个问题。

对于双向认证,其实民生也提供有 JAVASDK 可以使用,但是这是针对于 JAVA 项目而言的,如果想通过 LAJP 的方式进行调用,还需要参考 SDK 来封装代码(因为需要改造成静态方法,需要重新对民生提供的加密解密的 JAR 包进行解包、改造、编译、打包),据说大部分跟他们对接的使用 PHP 语言的商户采用的是这种方案。其实我是很反感引入这种所谓的中间件,因为这会导致项目的复杂性增加,同时影响运行稳定性。因此,我决定采用 PHPCURL 来自己处理 SSL 双向认证问题。

实现 CURLSSL 双向认证其实很简单,只需要在封装的方法中增加如下几行代码即可:

//  开启调试模式
curl_setopt($handle, CURLOPT_VERBOSE, '1');
// 证书格式
curl_setopt($handle, CURLOPT_SSLCERTTYPE, 'PEM');
// 证书路径
curl_setopt($handle, CURLOPT_SSLCERT, '/xxx/cert.pem');
// 证书密码|无密码则可不设置
curl_setopt($handle, CURLOPT_SSLCERTPASSWD, '123456');
// 私钥格式
curl_setopt($handle, CURLOPT_SSLKEYTYPE, 'PEM');
// 私钥路径
curl_setopt($handle, CURLOPT_SSLKEY, '/xxx/key.pem');

需要说明的是,民生银行提供的有用于 JAVA-SDK 来实现 SSL 双向认证的 JKS 文件,我从 JKS 中导出了秘钥 key.pemcert.pem

可以使用此工具 KeyStore Explorerhttp://keystore-explorer.org/ )来处理 JKS 文件。

以上 key.pem 的格式如下:

-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIE6jAcBgoqhkiG9w0BDAEDMA4ECP8UGEp4FY8HAgIExwSCBMiV/TafMh5acKTb
[省略N行]...
e+rRu8LkDBaKWNpLPZ8=
-----END ENCRYPTED PRIVATE KEY-----

以上 cert.pem 的格式如下:

-----BEGIN CERTIFICATE-----
MIIEyzCCA7OgAwIBAgIFMAGEFlEwDQYJKoZIhvcNAQEFBQAwKzELMAkGA1UEBhMC
[省略N行]...
ljiaZ6PKnf4kh/gcXVkzzoKAFVB/CNMCoo9Tg0y2qw==
-----END CERTIFICATE-----

可以通过以下命令去除私钥的密码:

openssl rsa -in key.pem -out key-without-pwd.pem 

如果一切正常的话,以上配置就能实现 PHPCURL SSL 双向认证。


但是,事与愿违,估计测试后 CURL 会报以下类似的错误:

unable to load client cert: -8018 (SEC_ERROR_UNKNOWN_PKCS11_ERROR)

这是因为 CentOS 自带的 CURL 版本为 NSS 而非 OPENSSL ,可以通过以下命令查看:

[root@PHPHa ~]# curl -V
curl 7.29.0 (x86_64-redhat-linux-gnu) libcurl/7.29.0 NSS/3.36 zlib/1.2.7 libidn/1.28 libssh2/1.4.3

从上面可以看到,当前系统的 CURL 版本为 NSS/3.36 ,因此我们需要重新编译 CURL

# 下载
[root@PHPHa ~]# wget http://curl.haxx.se/download/curl-7.35.0.tar.gz
# 解压
[root@PHPHa ~]# tar -zxf curl-7.35.0.tar.gz
[root@PHPHa ~]# cd curl-7.35.0
# 编译安装
[root@PHPHa curl-7.35.0]# ./configure --prefix=/usr/local/curl/ --without-nss --with-ssl
[root@PHPHa curl-7.35.0]# make && make install
# 替换命令
[root@PHPHa ~]# mv /usr/bin/curl /usr/bin/cur.bak
[root@PHPHa ~]# ln -s /usr/local/curl/bin/curl /usr/bin/curl

如果正常的话,再次查看 CURL 版本,就可以看到已经变更为 OpenSSL/1.0.2k

[root@PHPHa ~]# curl -V
curl 7.35.0 (x86_64-redhat-linux-gnu) libcurl/7.35.0 OpenSSL/1.0.2k zlib/1.2.7 libidn/1.28

以上测试可以编译成功的相关系统/软件版本如下:

CentOS-7.2-x64 curl-7.35.0 openssl-1.0.2k


但在另外一台服务器上就编译报错,最后更换了版本顺利通过,相关系统/软件版本如下:

CentOS-7.6-x64 curl-7.50.0 openssl-1.1.1

系统自带的为 openssl-1.0.2k ,因此需要先编译升级 openssl 。配置时注意需要加上 shared 参数,以生成动态库。

# 下载
[root@PHPHa ~]# wget https://www.openssl.org/source/old/1.1.1/openssl-1.1.1.tar.gz
# 解压
[root@PHPHa ~]# tar -zxf openssl-1.1.1.tar.gz
[root@PHPHa ~]# cd openssl-1.1.1
# 编译安装| `shared` 参数指定生成动态库
[root@PHPHa openssl-1.1.1]# ./config --prefix=/usr/local/openssl shared zlib
[root@PHPHa openssl-1.1.1]# make && make install
# 设置环境变量
[root@PHPHa ~]# vim /etc/profile
# 添加下面这一行
export PKG_CONFIG_PATH=/usr/local/openssl/lib/pkgconfig
# 使环境变量生效
[root@PHPHa ~]# source /etc/profile
# 替换命令
[root@PHPHa ~]# mv /bin/openssl /bin/openssl.bak
[root@PHPHa ~]# ln -s /usr/local/openssl/bin/openssl /bin/openssl

升级完成后,我们可以查看下当前的版本,正常情况应该如下:

[root@PHPHa ~]# openssl version
OpenSSL 1.1.1  11 Sep 2018

如果异常,可能会报如下的错误:

/usr/local/openssl/bin/openssl: error while loading shared libraries: libssl.so.1.1:

cannot open shared object file: No such file or directory

这是因为找不到对应的链接库,我们只需要创建个软连接即可:

[root@PHPHa ~]# ln -s /usr/local/openssl/lib/libssl.so.1.1 /usr/lib64/libssl.so.1.1
[root@PHPHa ~]# ln -s /usr/local/openssl/lib/libcrypto.so.1.1 /usr/lib64/libcrypto.so.1.1

openssl 升级成功后,按照上文重新编译安装 curl-7.50.0 即可。


紧接着,我们需要重新编译 PHP ,指定编译时对应的 CURL 安装路径。

# 查看当前编译配置
[root@PHPHa ~]# php -i | grep configure
Configure Command =>  './configure'  '--prefix=/usr/local/php' '--with-config-file-path=/usr/local/php/etc' '--with-config-file-scan-dir=/usr/local/php/conf.d' '--enable-fpm' '--with-fpm-user=www' '--with-fpm-group=www' '--enable-mysqlnd' '--with-mysqli=mysqlnd' '--with-pdo-mysql=mysqlnd' '--with-iconv-dir' '--with-freetype-dir=/usr/local/freetype' '--with-jpeg-dir' '--with-png-dir' '--with-zlib' '--with-libxml-dir=/usr' '--enable-xml' '--disable-rpath' '--enable-bcmath' '--enable-shmop' '--enable-sysvsem' '--enable-inline-optimization' '--enable-mbregex' '--enable-mbstring' '--enable-intl' '--with-mcrypt' '--enable-ftp' '--with-gd' '--enable-gd-native-ttf' '--with-openssl' '--with-curl' '--with-mhash' '--enable-pcntl' '--enable-sockets' '--with-xmlrpc' '--enable-zip' '--enable-soap' '--with-gettext' '--disable-fileinfo' '--enable-opcache' '--with-xsl'
# 切换目录
[root@PHPHa ~]# cd php-7.2.x
# 重新编译
[root@PHPHa php-7.2.x]# ./configure --prefix=/usr/local/php --with-config-file-path=/usr/local/php/etc --with-config-file-scan-dir=/usr/local/php/conf.d --enable-fpm --with-fpm-user=www --with-fpm-group=www --enable-mysqlnd --with-mysqli=mysqlnd --with-pdo-mysql=mysqlnd --with-iconv-dir --with-freetype-dir=/usr/local/freetype --with-jpeg-dir --with-png-dir --with-zlib --with-libxml-dir=/usr --enable-xml --disable-rpath --enable-bcmath --enable-shmop --enable-sysvsem --enable-inline-optimization --enable-mbregex --enable-mbstring --enable-intl --with-mcrypt --enable-ftp --with-gd --enable-gd-native-ttf --with-openssl --with-curl=/usr/local/curl --with-mhash --enable-pcntl --enable-sockets --with-xmlrpc --enable-zip --enable-soap --with-gettext --disable-fileinfo --enable-opcache --with-xsl
[root@Homike php-7.2.x]# make && make install

编译成功后,可以通过以下命令查看对应的 SSL 扩展版本。

[root@PHPHa ~]# php -i | grep 'SSL Version'
SSL Version => OpenSSL/1.0.2k
如果以上通过指定 CURL 安装目录重新编译的方式不行的话,那么也可以编译时去掉 --with-curl ,然后单独编译 CURL 扩展,并将生成的 curl.so 添加到配置文件 php.ini 中即可。

到此为止,算是实现了 PHP 版本的 CURL SSL 双向认证。至于通过 GMSSL 扩展来实现 PKCS#7 数字信封的方法,如有了解的朋友欢迎通过本博客 关于 页面中的联系邮箱进行沟通。

标签:PHP SSL 国密算法