纯PHP实现国密SM3哈希:PHP5老项目落地指南 简介面向PHP 5环境下的国密SM3算法实现适合需要国产密码算法支持的中小型业务系统开发者或PHP扩展研究人员。压缩包内含完整的PHP扩展源码与编译配置通过phpize5、configure、make等步骤即可生成sm3.so动态库并在代码中调用sm3_encode函数完成哈希运算作者已确认在PHP5环境下验证通过PHP7尚未验证目录中保留了configure、config.m4、Makefile等典型构建文件便于二次编译与排错。压缩包共49个文件以C源码.c/.h、PHP相关文件.php/.phpt、编译产物.so/.lo/.la/.o以及automake辅助脚本.m4/.awk/.in等类型为主整体约591KB结构紧凑适合快速集成。已有1212人学习下载可供需要快速部署国密哈希能力的开发人员参考。1. 国密 SM3 在 PHP5 落地为什么老项目反而更需要它接手一个跑在 PHP5 上的老系统业务方突然提出要对接某政务平台接口验签必须用国密 SM3。第一反应是去 Packagist 找个现成扩展结果发现主流密码库早就不维护 PHP5 了翻遍服务器又确认没法装编译扩展只能在纯 PHP 层把 SM3 搓出来。这就是本文要解决的问题在 PHP5 环境下用纯 PHP 实现国密 SM3 哈希并保证和标准测试向量逐字节一致。SM3 是国密标准里的哈希算法输出 256 位摘要广泛用于商用密码体系里的完整性校验、数字签名和消息认证。适合的人群是维护老系统的工程师、需要做等保合规改造的开发以及所有被环境锁死在 PHP5 又必须上国密的团队。本文会从算法结构讲起给出可直接复制的代码思路再重点拆解字节序、补位、填充这些最容易翻车的地方。2. SM3 算法结构拆解先搞懂它和 MD5/SHA-256 的差别再写代码才不玄学2.1 消息扩展与压缩函数的迭代逻辑SM3 的整体流程是输入消息先做填充补到长度为 512 的倍数然后每 512 比特64 字节为一个分组每个分组经过消息扩展产生 132 个 32 位字再用 64 轮压缩函数迭代最后输出 256 比特摘要。这个骨架和 SHA-256 很像但细节完全不同SM3 的压缩函数里用了两个专门的置换函数 P0 和 P1还有布尔函数 FF 和 GG它们的定义直接决定了每轮迭代里的移位和异或顺序。值得注意的是SM3 的 64 轮不是简单重复同一个操作前 16 轮和后 48 轮使用的 FF、GG 定义不同所以代码里必须按轮数切换分支。消息扩展部分更是关键每一轮要用到 W[j]它是由 W[j] 和 W[j4] 异或后再左移得到的这个左移位数是固定的 15 位。很多移植失败的案例都是因为把 W[j] 的生成时机搞错了——必须在第 13 轮之后才开始用因为 W[0] 到 W[11] 生成时根本取不到 W[j4]。2.2 为什么 PHP5 环境下不能依赖 OpenSSL 扩展PHP5 的 OpenSSL 扩展在绝大多数发行版里根本不自带 SM3 算法即使个别版本通过补丁支持了也因为编译参数和 OpenSSL 版本差异导致行为不一致生产环境里很难指望它。更现实的是很多老项目的 PHP5 是 yum/apt 装的重新编译 PHP 等于动整个线上环境风险不可控。纯 PHP 实现虽然慢但有一个不可替代的好处只要 PHP 能跑代码就能跑不依赖任何外部库也不受编译选项影响。实测下来纯 PHP 处理几 KB 的报文做验签耗时在毫秒级到几十毫秒级完全够用。对于大文件哈希这种场景纯 PHP 确实吃力但国密在政企对接里往往出现在签名验签、口令摘要这些短数据场景性能不是瓶颈。所以我的建议是能装扩展就装扩展不能装就纯 PHP先把正确性保证好再谈性能。SM3 的纯 PHP 实现本质上就是把这几个布尔函数和循环移位用 PHP 的整数运算老老实实写出来。2.3 核心运算的 PHP 实现循环左移与 32 位溢出处理PHP 的整数是 64 位有符号的做 32 位无符号运算必须小心符号位。循环左移的标准写法是(($x $n) | ($x (32 - $n))) 0xFFFFFFFF这里的 0xFFFFFFFF必须加否则左移结果会把高位带到 64 位空间里导致后续异或结果错乱。PHP5 里没有无符号右移所以右移时必须自己先做一个掩码处理把符号位去掉。更稳的做法是定义两个小函数function sm3Rotl($x, $n) { return (($x $n) | ($x (32 - $n))) 0xFFFFFFFF; } function sm3Rotr($x, $n) { return ($x $n) | (($x (32 - $n)) 0xFFFFFFFF); }逻辑说明sm3Rotl先左移 n 位和右移 32-n 位两者按位或后取低 32 位得到的就是循环左移的效果。右移版本同理但要注意左移部分必须做掩码否则 PHP 会把符号位扩散。参数说明$x是 32 位无符号整数PHP 里可能是负数函数内部通过掩码强制转回正数$n的取值范围是 1 到 31SM3 标准里只用到了少数几个固定值7、12、15、17、23 等。这两个函数是后面所有轮函数的基础建议单独放一个文件里避免重复写。3. 从零写出可用的 SM3 纯 PHP 类补位、分组、迭代一次说清3.1 消息补位为什么长度要拆成两个 32 位字来存SM3 的填充规则是原始消息后面先补一个 0x80然后补 0x00 直到长度 mod 512 448最后附加原始消息长度的 64 位大端整数。这个长度是比特长度不是字节长度千万别搞混。64 位长度在 PHP5 里不能直接用一个整数表示因为 32 位 PHP 的整数会溢出即使 64 位 PHP 也要先把长度拆成高低各 32 位。补位长度可以直接按字节来算但存储时必须拆开function sm3Padding($data) { $len strlen($data); $bitLen $len * 8; // 先补 0x80 $data . \x80; // 补 0x00 直到长度模 64 等于 56 while ((strlen($data) % 64) ! 56) { $data . \x00; } // 附加 64 位长度大端拆成两个 32 位 $high ($bitLen 0xFFFFFFFF00000000) 32; $low $bitLen 0xFFFFFFFF; $data . pack(N2, $high, $low); return $data; }逻辑说明先拼 0x80 标记填充开始再用循环补零直到长度满足模 64 余 56最后用pack(N2)把 64 位长度按两个无符号 32 位大端序写入。参数说明pack(N2)是 PHP 里专门打大端 32 位无符号数的格式两个参数分别对应高 32 位和低 32 位。这里的关键坑是$bitLen 0xFFFFFFFF00000000在 32 位 PHP 上会直接变成 0因为整数根本存不下那么大的数如果目标环境是 32 位 PHP必须用字符串拆分长度。64 位 PHP 上这段代码没问题但为了兼容性更稳妥的做法是把长度拆成字节操作而不是依赖整数位运算。3.2 分组处理与初始向量 IV 的设定填充完成后消息就是 64 字节的整数倍。SM3 的初始向量 IV 是固定的八个 32 位常数和 SHA-256 的初始值完全不同直接从国密标准里抄来就行。IV 是十六进制字符串在 PHP 里最好用一个数组存整数避免每次用hexdec反复转换private $iv array( 0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600, 0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E );然后按 64 字节切分消息每个分组独立处理当前分组的压缩结果和上一轮的结果合并存到寄存器 A 到 H 里。这里有个容易被忽略的细节每个分组处理完不是直接覆盖 IV而是把当前分组算出来的 V 值和上一轮的 V 值逐字异或作为下一轮的输入。初始 V 就是 IV最后一轮异或完的 V 就是摘要。每组处理共享同一个上下文所以严格来说 SM3 和 MD5 一样是 Merkle-Damgård 结构分组的处理顺序不能乱也不能并行必须串行。3.3 消息扩展用数组存下 W[0..67] 和 W[0..63]SM3 每个分组要扩展出 68 个 W 和 64 个 W。W[0] 到 W[15] 直接来自分组的 16 个 32 位字之后的 W[j] 由 W[j-16]、W[j-9]、W[j-3] 等经过异或、循环左移和 P1 函数生成。W[j] 是 W[j] 异或 W[j4] 再循环左移 15 位。实现时不需要一次性算出所有 W可以在压缩循环里边算边用但为了逻辑清晰我建议先把整个扩展数组算好这样排查问题时能直接打印中间值。扩展的伪代码是先for ($j 0; $j 16; $j)从分组数据里unpack出 16 个 32 位大端整数然后for ($j 16; $j 68; $j)用迭代公式生成。需要特别注意的是 W[j] 的生成必须在 W[j] 可用之后而 W[j] 从第 16 个才开始迭代生成所以 W[j] 的完整生成是从 j16 到 j63 的循环里同步做的。3.4 压缩函数逐行走读T 常量与轮函数分支压缩函数是 SM3 最核心的部分每轮操作要更新 A 到 H 八个寄存器。第 j 轮先算SS1 rotl((rotl(A, 12) E rotl(Tj, j)) 0xFFFFFFFF, 7)这个加法必须做 32 位截断否则进位会扩散。然后算SS2 SS1 ^ rotl(A, 12)TT1 和 TT2 分别是 FF 函数和 GG 函数参与的结果最后按顺序更新寄存器。Tj 的值分两段j 从 0 到 15 时 Tj 等于 0x79CC4519j 从 16 到 63 时 Tj 等于 0x7A879D8A。注意这里 Tj 参与运算时不是直接用而是rotl(Tj, j)所以 Tj 本身不能预先固定成一个值必须每轮都计算循环左移。FF 函数在 j 小于 16 时是X ^ Y ^ Z大于等于 16 时变成(X Y) | (X Z) | (Y Z)GG 函数在 j 小于 16 时是X ^ Y ^ Z大于等于 16 时变成(X Y) | (~X Z)。这些分支不复杂但很容易在复制代码时把两个分支写反导致中间向量错乱。写完压缩函数后用标准测试向量验证一步错就能在一个分组后看到摘要完全不对。4. 完整 SM3 类的组织方式与调用示例照着抄就能跑通4.1 一个文件的完整类骨架实际生产里我会把 SM3 封装成一个类静态方法方式提供避免每次调用都要手动管理 IV 和分组。下面是一个最小完整实现的可运行骨架补位、扩展、压缩、摘要输出都在里面class SM3 { private static $iv array( 0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600, 0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E ); private static function rotl($x, $n) { return (($x $n) | ($x (32 - $n))) 0xFFFFFFFF; } private static function p0($x) { return $x ^ self::rotl($x, 9) ^ self::rotl($x, 17); } private static function p1($x) { return $x ^ self::rotl($x, 15) ^ self::rotl($x, 23); } private static function ff($x, $y, $z, $j) { return $j 16 ? ($x ^ $y ^ $z) : (($x $y) | ($x $z) | ($y $z)); } private static function gg($x, $y, $z, $j) { return $j 16 ? ($x ^ $y ^ $z) : (($x $y) | (~$x $z)); } public static function hash($data) { $data self::padding($data); $v self::$iv; $len strlen($data); for ($i 0; $i $len; $i 64) { $block substr($data, $i, 64); $v self::compress($block, $v); } $result ; foreach ($v as $word) { $result . pack(N, $word); } return bin2hex($result); } }逻辑说明这个骨架把 P0、P1、FF、GG 全部拆成独立方法可读性比一个大函数好很多。hash方法负责填充、分组、压缩三轮调用最后把八个寄存器的值按大端序 pack 成二进制再转十六进制。参数说明$data是原始二进制字符串不要传已经 hex 编码的字符串否则哈希结果会不一致。$iv是静态属性PHP5 里静态数组不能用常量表达式初始化所以只能用这种写法。这里的压缩函数compress和填充函数padding我在前面已经给了核心逻辑补充完整后即可运行。4.2 标准测试向量验证摘要正确性的唯一标准写完代码后第一件事是用国密标准文档里的测试向量验证。标准里给了空串、字符串 abc 和一段长文的预期摘要其中 abc 的 SM3 摘要应该是固定的 64 位十六进制字符串。下面是验证方式$testCases array( 1ab21d8355cfa17f8e61194831e81a8f22bec8c728fefb747ed035eb5082aa2b, abc 66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0 ); foreach ($testCases as $input $expected) { $actual SM3::hash($input); echo ($actual $expected) ? PASS : FAIL: $actual; echo \n; }逻辑说明这组测试向量来自国密标准公开样例如果你的实现连 abc 都对不上那一定是补位或寄存器更新顺序出了问题。参数说明注意空串也要测空串的填充完全靠补位逻辑能暴露出补 0x80 后循环补零的边界问题。如果空串通过但 abc 失败问题大概率出在消息扩展的 W[j] 生成时机上。测试向量不需要多一个短串加一个长串就够定位方向。这里特别提醒千万不要拿 MD5 或 SHA-256 的测试向量来验 SM3两者之间没有任何中间值可以对照错了就是错了。4.3 大端序字符串如何转成 32 位字分组处理的起点是把 64 字节的字符串转成 16 个 32 位大端整数。PHP 的unpack函数是唯一可靠的方式字符串按字节解析不依赖平台字节序$words unpack(N16, $block); $w array_values($words);逻辑说明N16表示解析 16 个无符号 32 位大端整数返回数组的键是从 1 开始的下标所以用array_values重新索引成从 0 开始的数组方便后续按 j 取值。参数说明$block必须正好 64 字节否则unpack会报错这也是为什么填充逻辑必须以 64 为模。如果unpack出来的值看起来不对先检查$block的长度再检查文件编码是否为 UTF-8 无 BOM否则中文字符串的字节数会和你预期的不一致。这一步是整个 SM3 实现里最容易藏坑的地方之一因为unpack出来的N格式在 PHP 里是无符号的但后续位运算会把高位当符号位处理所以中间所有运算都要记得做掩码。5. SM3 PHP 实现的五个常见踩坑点现象、原因、解决一条龙5.1 摘要结果整体错乱连 abc 都对不上现象用标准测试向量验证任何输入的摘要都和预期完全不一样。原因80% 是循环左移的掩码丢了PHP 的整数左移会把高位移到 64 位空间不取低 32 位直接参与异或整个扩散就乱了。另外 FF/GG 函数的~$x在 PHP 里是 64 位取反负数的补码会让位运算结果完全偏离预期。解决所有中间结果统一用 0xFFFFFFFF收拢~$x改成($x ^ 0xFFFFFFFF)并且再跟 0xFFFFFFFF确保全程 32 位语义。5.2 英文和数字摘要正确中文字符串摘要不对现象纯 ASCII 输入测出来的摘要和标准一致但输入中文后结果全乱。原因文件编码不对。如果 PHP 文件本身是 GBK 编码字符串字面量里的中文会按 GBK 字节序列送进哈希函数而标准测试向量是基于 UTF-8 字节序列的。服务器上实际输入也一样前端传过来的中文可能是 URL 编码也可能是 UTF-8必须统一在进 SM3 之前转成 UTF-8 原始字节。解决入口处强制mb_convert_encoding($data, UTF-8, auto)再用bin2hex检查字节序是否符合预期。5.3 再造数据原串校验时提示摘要不一致现象已经算出来并且妥妥存库的摘要下次用同一串原文去算结果变了。原因调用方在不知情的情况下对原文做了 trim、转义或者大小写转换。这个坑不在 SM3 实现本身而在调用链路上。解决在封装 SM3 类的入口留一个 debug 参数把送入hash函数的字符串用bin2hex打出来对比定位是哪一层动了数据。这种问题在对接第三方平台时常见对方文档里写了原文格式但实际传参时签名串拼接方式和你理解的不一样。5.4 相同输入在 32 位和 64 位 PHP 上结果不同现象同一段代码在开发机64 位跑通全部测试向量部署到 32 位生产环境后摘要对不上。原因32 位 PHP 的整数最大值是 2^31-10xFFFFFFFF这种字面量会被解释成 float所有位运算结果都变成 float后续pack(N)强制转 int 时溢出。解决不要直接写十六进制超长字面量写成~0或者用intval(4294967295)这种字符串转换方式进一步的方案是干脆放弃整数位运算用字符串位移函数模拟循环左移这样彻底绕开 32 位整数边界。5.5 性能严重劣化哈希一个几 KB 文件要好几秒现象纯 PHP 实现处理 4KB 以上数据耗时几百毫秒到几秒。原因每个字节的位运算和函数调用开销很大而且在压缩函数里频繁做字符串处理。解决如果业务场景是大文件纯 PHP 直接放弃改用 C 扩展或者命令行工具如果只是验签短字符串可以接受。实测中把压缩函数里的rotl调用改成内联的位运算表达式性能能提升 30% 左右。再不行就把每个分组的字符串转成整数数组后整个分组处理都保持在数组和整数运算里避免反复substr和unpack。6. 把 SM3 放进生产链路封装成签名组件时的两个习惯6.1 习惯一所有十六进制输入先校验再决定解码方式对接方传过来的摘要和原文未必是干净的十六进制字符串。常见情况是摘要里混着大写字母或者原文做过了 Base64。我通常会在 SM3 封装类外面再包一层签名服务进 SM3 之前统一处理格式输入必须是 UTF-8 原始字节摘要输出统一小写十六进制。如果对方请求里带的是大写十六进制比较时先转小写再比对否则会因为大小写差异误判验签失败。这个规则应该写进接口文档并在代码入口做断言违规输入直接抛异常而不是静默转换。这样后续排查问题的时候日志里能清楚看到是哪一层出了问题。6.2 习惯二把测试向量用例写进单测改动一次跑一次别小看这条。我见过太多人把 SM3 代码写好、验证通过、上线后就不动了结果某天因为 PHP 版本升级、服务器迁移或者代码合并某个位运算的掩码被无意删掉摘要全部错乱。如果从一开始就把测试向量固化成一个单测方法每次改动代码后跑一遍这类问题当场就能暴露。测试向量不需要多空串、abc、超过一个分组的长文本各一条足矣。补充一句如果你后续要把这段代码移植到 PHP7 或 PHP8 环境测试向量同样可以直接复用移植的正确性验证就是跑通这三条用例。这是整个方案里性价比最高的一步希望帮到你。本文还有配套的精品资源点击获取