使用PHP国密实战,打通SM2/SM3/SM4全流程
作为一个PHP开发者,面对国密(SM2/SM3/SM4)这个“硬骨头”,第一反应是:PHP生态有成熟的解决方案吗?
经历了从迷茫到实现的全过程,我最终用 PHP GMP扩展 + lpilp/guomi Composer包 优雅地解决了所有国密加解密需求。如果你也在为国密开发头疼,这篇文章就是为你准备的实战指南。
国密是什么?为什么突然火了?
国密算法是中国自主研发的密码标准:
SM2:椭圆曲线公钥密码算法(替代RSA)
SM3:密码杂凑算法(类似SHA-256)
SM4:分组密码算法(类似AES)
随着网络安全法的实施和信创(信息技术应用创新)产业的推进,金融、政务、医疗等关键领域都在加速国密改造。
PHP国密开发的三大挑战
生态薄弱:相比Java/C++,PHP国密生态相对匮乏
性能瓶颈:纯PHP实现的国密算法性能堪忧
兼容性问题:与现有系统的平滑过渡
技术选型之路
尝试方案一:纯PHP扩展(放弃)
编译安装php-gmssl等扩展,过程繁琐且版本兼容性差。
尝试方案二:调用外部服务(不适合)
通过API调用密码机服务,增加网络开销和单点故障风险。
最终方案:GMP扩展 + lpilp/guomi(完美解决)
# 1. 安装GMP扩展(大多数环境已内置)
sudo apt-get install php-gmp # Ubuntu/Debian
# 或编辑php.ini启用:extension=gmp
# 2. Composer一键安装国密库
composer require lpilp/guomi
实战代码:三分钟搞定国密加解密
Sm2PrivateKeyParserUtils.php
<?php
namespace App\Utils;
/**
* 纯 PHP 解析 PKCS#8 PEM 提取 SM2 私钥 d 值
* 不依赖 OpenSSL 命令行,只需要 PHP 的数学扩展
*/
class Sm2PrivateKeyParserUtils
{
/**
* 从 PKCS#8 PEM 中提取 SM2 私钥 d 值(16进制)
*/
public static function extractPrivateKeyFromPkcs8(string $pem): string
{
// 1. 解码 PEM 格式
$der = self::pemToDer($pem);
// 2. 解析 ASN.1 DER 结构
$data = self::parseAsn1Der($der);
// 3. 提取私钥数据
return self::extractSm2PrivateKey($data);
}
/**
* PEM 转换为 DER
*/
private static function pemToDer(string $pem): string
{
// 移除 PEM 头尾和空白字符
$pem = preg_replace('/-----(BEGIN|END)[\w\s]+-----/', '', $pem);
$pem = preg_replace('/\s+/', '', $pem);
// Base64 解码
$der = base64_decode($pem);
if ($der === false) {
throw new \RuntimeException('Base64 解码失败');
}
return $der;
}
/**
* 简单的 ASN.1 DER 解析器
*/
private static function parseAsn1Der(string $der): array
{
$offset = 0;
return self::parseAsn1Value($der, $offset);
}
/**
* 解析单个 ASN.1 值
*/
private static function parseAsn1Value(string $der, int &$offset)
{
if ($offset >= strlen($der)) {
throw new \RuntimeException('ASN.1 解析越界');
}
$tag = ord($der[$offset++]);
$length = self::parseAsn1Length($der, $offset);
$value = substr($der, $offset, $length);
$offset += $length;
// 检查是否是序列
if (($tag & 0x1F) === 0x10) {
return self::parseAsn1Sequence($value);
}
// 检查是否是八位组串
if (($tag & 0x1F) === 0x04) {
return $value;
}
// 检查是否是整数
if (($tag & 0x1F) === 0x02) {
return $value;
}
return $value;
}
/**
* 解析 ASN.1 长度字段
*/
private static function parseAsn1Length(string $der, int &$offset): int
{
$length = ord($der[$offset++]);
if ($length & 0x80) {
$numBytes = $length & 0x7F;
$length = 0;
for ($i = 0; $i < $numBytes; $i++) {
$length = ($length << 8) | ord($der[$offset++]);
}
}
return $length;
}
/**
* 解析 ASN.1 序列
*/
private static function parseAsn1Sequence(string $data): array
{
$result = [];
$offset = 0;
while ($offset < strlen($data)) {
try {
$result[] = self::parseAsn1Value($data, $offset);
} catch (\Exception $e) {
break;
}
}
return $result;
}
/**
* 从解析的数据中提取 SM2 私钥
*/
private static function extractSm2PrivateKey(array $data): string
{
// PKCS#8 结构通常是:
// Sequence[
// Integer(version = 0),
// Sequence[OID, params],
// OctetString(privateKey)
// ]
if (count($data) < 3) {
throw new \RuntimeException('无效的 PKCS#8 结构');
}
// 私钥在第三个元素(八位组串)中
$privateKeyOctet = $data[2];
if (!is_string($privateKeyOctet)) {
throw new \RuntimeException('私钥数据不是字符串');
}
// EC 私钥的结构通常是:
// Sequence[
// Integer(version = 1),
// OctetString(privateKey d)
// [optional parameters]
// ]
$privateKeyData = self::parseAsn1Der($privateKeyOctet);
if (count($privateKeyData) < 2) {
throw new \RuntimeException('无效的 EC 私钥结构');
}
// 私钥 d 值在第二个元素中
$dValue = $privateKeyData[1];
if (!is_string($dValue)) {
throw new \RuntimeException('私钥 d 值不是字符串');
}
// 转换为16进制
$hex = bin2hex($dValue);
// 清理前导零并填充到64字符
$hex = ltrim($hex, '0');
$hex = str_pad($hex, 64, '0', STR_PAD_LEFT);
return substr($hex, -64);
}
}
gmssl.php
<?php
/**
* 国密算法测试用例
* 使用 lpilp/guomi 扩展包测试 SM2/SM3/SM4 算法
*
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Rtgm\sm\RtSm2;
use Rtgm\sm\RtSm3;
use Rtgm\sm\RtSm4;
use App\Utils\Sm2PrivateKeyParserUtils;
class GmsslTest
{
// SM2 解密私钥(PEM 格式)
private const SM2_PRIVATE_KEY = <<<EOT
-----BEGIN PRIVATE KEY-----
你的解密私钥
-----END PRIVATE KEY-----
EOT;
private const SLVAS_SYS_SM4_KEY = '你的sys key';
private const SLVAS_BUS_SM4_KEY = '你的bus key';
private const SUPPLIER_CODE = ' '; // 供应商编码
/**
* 解密 body 数据
* @param string $encryptedBody 加密的 body(BCD 格式十六进制字符串或 base64)
* @return array|false 解密后的数据
*/
public function sm2decrypt(string $encryptedBody)
{
try {
$sm2 = new RtSm2('hex'); // 'hex' or 'base64' 主要影响签名输出格式,对加解密无所谓
$cipherHex = $encryptedBody; // Hutool encryptBcd() 的返回值(一般就是 hex 字符串)
$privateKeyHex = Sm2PrivateKeyParserUtils::extractPrivateKeyFromPkcs8(self::SM2_PRIVATE_KEY);
$bodyData = $sm2->doDecrypt($cipherHex, $privateKeyHex, true, C1C3C2);
return $bodyData;
} catch (\Exception $e) {
echo "<pre>";
print_r([
'file' => $e->getFile(),
'line' => $e->getLine(),
'error' => $e->getMessage(),
]);
echo "</pre>";
exit;
}
}
/**
* sm3 验证签名
*/
public function verifySign(string $inspectionSerialNo, string $sign)
{
try {
$received = strtolower(trim($sign));
if ($received === '') {
return false;
}
$sm3 = new RtSm3();
// 约定:将 inspectionSerialNo(或按约定拼接后的字符串)进行 SM3,得到 64 位 hex
// 由于目前“拼接规则”未完全明确,这里做两种候选兼容:
// 1) inspectionSerialNo
// 2) SUPPLIER_CODE + inspectionSerialNo
$candidates = [
$inspectionSerialNo,
self::SUPPLIER_CODE . $inspectionSerialNo,
];
foreach ($candidates as $payload) {
$computed = strtolower($sm3->digest($payload));
if (hash_equals($computed, $received)) {
return true;
}
}
return false;
} catch (\Exception $e) {
}
}
// busSm4Key 加密
public function sm4EncryptBus($string)
{
$sm4 = new RtSm4(self::SLVAS_BUS_SM4_KEY);
return $sm4->encrypt($string, 'sm4-ecb', '', 'base64');
}
// busSm4Key 解密
public function sm4DecryptBus($string)
{
$sm4 = new RtSm4(self::SLVAS_BUS_SM4_KEY);
return $sm4->decrypt($string, 'sm4-ecb', '', 'base64');
}
// sysSm4Key 加密
public function sm4EncryptSys($string)
{
$sm4 = new RtSm4(self::SLVAS_SYS_SM4_KEY);
return $sm4->encrypt($string, 'sm4-ecb', '', 'base64');
}
// sysSm4Key 解密
public function sm4DecryptSys($string)
{
$sm4 = new RtSm4(self::SLVAS_SYS_SM4_KEY);
return $sm4->decrypt($string, 'sm4-ecb', '', 'base64');
}
}
error_reporting(E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR);
echo " ==================sm2 解密============================== <br/>";
$encryptedData = '你的加密字符串';
echo "sm2密文: ".$encryptedData."<br>";
$bodyData = (new GmsslTest())->sm2decrypt($encryptedData);
echo "sm2 解密后的数据:<br>";
echo "<pre>";
print_r($bodyData);
echo "</pre>";
echo "<br/>";
echo " ==================sm3 签名验证============================== <br/>";
echo "inspectionSerialNo:RZXB-1769147224728-000001 <br>";
$inspectionSerialNo = 'RZXB-1769147224728-000001';
$sign = '6a5b67ea4123b54d85c8857f168cdccf8b47c9ea8b4fe849f3c33838f82b2a01';
echo "签名: sign:6a5b67ea4123b54d85c8857f168cdccf8b47c9ea8b4fe849f3c33838f82b2a01<br>";
$sign = (new GmsslTest())->verifySign($inspectionSerialNo, $sign);
echo "签名验证结果:<br>";
var_dump($sign);
echo "<br/>";
echo "<hr/>";
echo " ==================sm4 加密解密============================== <br/>";
$encryptedData = (new GmsslTest())->sm4EncryptBus('hello');
echo "使用 busSm4Key 加密后的数据: ".$encryptedData."<br>";
$decryptedData = (new GmsslTest())->sm4DecryptBus($encryptedData);
echo "使用 busSm4Key 解密后的数据: ".$decryptedData."<br>";
echo "<hr/>";
$encryptedData = (new GmsslTest())->sm4EncryptSys('hello');
echo "使用 sysSm4Key 加密后的数据: ".$encryptedData."<br>";
$decryptedData = (new GmsslTest())->sm4DecryptSys($encryptedData);
echo "使用 sysSm4Key 解密后的数据: ".$decryptedData."<br>";
exit;
上线 Checklist
- GMP扩展已安装并启用
- 生产环境密钥妥善保管(≠提交到Git)
- 与上下游系统完成国密联调
- 性能压力测试通过
- 制定密钥轮换方案
本文代码已在PHP8.4环境测试通过,欢迎留言交流经验!
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 2周前 自动加精
关于 LearnKu
国密 我开发过很多项目,为了方便大家调试 开发了gm-gui 工具
最近又接了一个 bcprov-jdk15on的, 问 AI 调通了,里面公钥pem 提取也扩展了 Sm2PublicKeyParserUtil 的类。本身 lpilp/guomi 有一个 MyAsn1 类的。