PHP 实现国密 SM3 踩坑记录:HMAC-SM3 签名对接 CNR 接口

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 位截断",别偷懒。 希望这篇记录能帮到同样在对接国密接口的同学。

相关推荐
mqiqe1 小时前
线程调度与 Schedulers:Project Reactor 并发模型的核心引擎
java·开发语言·网络
xixiaoyunya2 小时前
JavaScript 异步编程全解析:从回调地狱到 async/await 的演进之路
开发语言·javascript·ecmascript
精英的英3 小时前
记一次 Qt5 Language Server 开发
开发语言·vscode·qt
烧酒同学3 小时前
【C++】记录size of std::vector的巧妙坑
开发语言·c++·图形渲染
周周哈哈哈3 小时前
线程创建、执行、退出、回收
java·开发语言
gb42152873 小时前
python中Web应用服务器
开发语言·前端·python
萧瑟余晖4 小时前
Java深入解析篇三十六之分布式系统详解
java·开发语言·分布式
吴声子夜歌5 小时前
网络安全——网络渗透测试(密码破译)
网络·web安全·php
ZJU_统一阿萨姆5 小时前
【推理优化进阶】调度器的数学内核:排队论、SLO 与在线决策
开发语言·人工智能·语言模型·系统架构·vllm