PHP 实现国密 SM3 踩坑记录:HMAC-SM3 签名对接 CNR 接口
背景
最近在对接一个公告接口,接口要求使用 HMAC-SM3 进行签名认证。SM3 是国家密码管理局发布的国产哈希算法,属于国密标准(GB/T 32905-2016),在政务和金融系统中越来越常见。
Java 侧用的是 BouncyCastle 的 HMac + SM3Digest,一切正常。但到了 PHP 这边,网上能找到的 SM3 实现大多有坑------算出来的签名和 Java 对不上,接口直接返回 401。
问题出在哪
核心问题就一个:PHP 整数溢出。
SM3 算法内部大量使用 32 位无符号整数运算,每一步加法、位移都需要严格截断为 32 位。但网上很多 PHP 版本的 SM3 实现,在 cf() 压缩函数里漏掉了截断操作:
这里不做更多解释,直接上代码
最终可用代码
以下是经过验证、与 Java BouncyCastle 字节级一致的完整实现:
php
<?php
namespace tool;
class Sm3
{
private const IV = [
0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600,
0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e
];
private const T = [
0x79cc4519, 0x79cc4519, 0x79cc4519, 0x79cc4519,
0x79cc4519, 0x79cc4519, 0x79cc4519, 0x79cc4519,
0x79cc4519, 0x79cc4519, 0x79cc4519, 0x79cc4519,
0x79cc4519, 0x79cc4519, 0x79cc4519, 0x79cc4519,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
0x7a879d8a, 0x7a879d8a, 0x7a879d8a, 0x7a879d8a,
];
/**
* 计算 SM3 哈希值(十六进制字符串)
*/
public static function hash(string $data): string
{
return bin2hex(self::digest($data));
}
/**
* 计算 SM3 摘要(二进制字符串)
*/
public static function digest(string $data): string
{
$padding = self::padding($data);
$blocks = str_split($padding, 64);
$v = self::IV;
foreach ($blocks as $block) {
$v = self::cf($v, $block);
}
$result = '';
foreach ($v as $word) {
$result .= pack('N', $word);
}
return $result;
}
/**
* 计算 HMAC-SM3(十六进制字符串)
*/
public static function hmac(string $key, string $data): string
{
$blockSize = 64;
if (strlen($key) > $blockSize) {
$key = self::digest($key);
}
if (strlen($key) < $blockSize) {
$key = str_pad($key, $blockSize, "\0");
}
$ipad = $key ^ str_repeat("\x36", $blockSize);
$opad = $key ^ str_repeat("\x5c", $blockSize);
$inner = self::digest($ipad . $data);
return bin2hex(self::digest($opad . $inner));
}
// ---------- 私有方法 ----------
private static function padding(string $data): string
{
$len = strlen($data);
$bitLen = $len * 8;
// 计算填充长度(不含最后的8字节长度域)
$padLen = (56 - ($len % 64) + 64) % 64;
if ($padLen === 0) {
$padLen = 64; // 当余数==56时,补满一个块
}
$padding = $data . "\x80" . str_repeat("\0", $padLen - 1);
// 追加64位大端长度
if (PHP_INT_SIZE === 8) {
$padding .= pack('J', $bitLen);
} else {
$padding .= pack('N*', ($bitLen >> 32) & 0xFFFFFFFF, $bitLen & 0xFFFFFFFF);
}
return $padding;
}
private static function cf(array $v, string $block): array
{
$w = array_values(unpack('N*', $block));
for ($j = 16; $j < 68; $j++) {
$w[$j] = self::p1($w[$j - 16] ^ $w[$j - 9] ^ self::rotl($w[$j - 3], 15))
^ self::rotl($w[$j - 13], 7)
^ $w[$j - 6];
}
$w1 = array_slice($w, 0, 64);
for ($j = 0; $j < 64; $j++) {
$w1[$j] = $w[$j] ^ $w[$j + 4];
}
$a = $v[0];
$b = $v[1];
$c = $v[2];
$d = $v[3];
$e = $v[4];
$f = $v[5];
$g = $v[6];
$h = $v[7];
for ($j = 0; $j < 64; $j++) {
// 所有加法均通过 & 0xFFFFFFFF 截断为32位
$ss1 = self::rotl(
(self::rotl($a, 12) + $e + self::rotl(self::T[$j], $j % 32)) & 0xFFFFFFFF,
7
);
$ss2 = $ss1 ^ self::rotl($a, 12);
$tt1 = (self::ff($j, $a, $b, $c) + $d + $ss2 + $w1[$j]) & 0xFFFFFFFF;
$tt2 = (self::gg($j, $e, $f, $g) + $h + $ss1 + $w[$j]) & 0xFFFFFFFF;
$d = $c;
$c = self::rotl($b, 9);
$b = $a;
$a = $tt1;
$h = $g;
$g = self::rotl($f, 19);
$f = $e;
$e = self::p0($tt2);
}
return [
$v[0] ^ $a,
$v[1] ^ $b,
$v[2] ^ $c,
$v[3] ^ $d,
$v[4] ^ $e,
$v[5] ^ $f,
$v[6] ^ $g,
$v[7] ^ $h,
];
}
/**
* 循环左移(内部自动截断为32位)
*/
private static function rotl(int $x, int $n): int
{
$n %= 32;
$x &= 0xFFFFFFFF;
if ($n === 0) {
return $x;
}
return (($x << $n) & 0xFFFFFFFF) | (($x >> (32 - $n)) & 0xFFFFFFFF);
}
private static function ff(int $j, int $x, int $y, int $z): int
{
return $j < 16 ? ($x ^ $y ^ $z) : (($x & $y) | ($x & $z) | ($y & $z));
}
private static function gg(int $j, int $x, int $y, int $z): int
{
return $j < 16 ? ($x ^ $y ^ $z) : (($x & $y) | (~$x & $z));
}
private static function p0(int $x): int
{
return $x ^ self::rotl($x, 9) ^ self::rotl($x, 17);
}
private static function p1(int $x): int
{
return $x ^ self::rotl($x, 15) ^ self::rotl($x, 23);
}
}
用法
php
use tool\Sm3;
// 计算 HMAC-SM3
$signature = Sm3::hmac($secretKey, $stringToSign);
dump($signature);
// 计算普通 SM3 哈希
$hash = Sm3::hash('hello world');
dump($hash);
验证方法
确保和 Java 对齐的最简单方式:用同一个 key 和 data,分别跑 PHP 和 Java,对比输出的十六进制字符串是否完全一致。
php
// PHP
dump(Sm3::hmac('8f91d2a73c6e9b4d7f8a1c2e3b4d5f6a', 'abc'));
java
// Java (BouncyCastle)
byte[] mac = hmacSm3(
"8f91d2a73c6e9b4d7f8a1c2e3b4d5f6a".getBytes(StandardCharsets.UTF_8),
"abc".getBytes(StandardCharsets.UTF_8)
);
System.out.println(bytesToHex(mac));
两边输出一样,就说明没问题了。
总结
| 坑点 | 后果 | 解决 |
|---|---|---|
| PHP 整数不自动截断 32 位 | 哈希值错误,签名对不上 | 所有运算加 & 0xFFFFFFFF |
rotl() 没做输入截断 |
高位脏数据污染结果 | 入口处 $x &= 0xFFFFFFFF |
| 填充长度计算边界错误 | 最后一块数据错乱 | 余数 == 56 时补满 64 字节 |
国密算法在 PHP 中的实现,核心就是"老老实实做 32 位截断",别偷懒。 希望这篇记录能帮到同样在对接国密接口的同学。