PHP实现RC6加密算法:源码解析与整数溢出处理 如果你正在维护一套老 PHP 系统或者纯粹对加密算法的底层实现感兴趣看到 RC6 这个名字应该不会太陌生。它是当年 AES 征选时的候选算法之一虽然最终没有胜出但它的设计思路非常经典靠数据依赖的循环移位和整数乘法做扩散在一个很小的硬件 footprint 上实现了很强的安全性。要在 PHP 里把它完整实现一遍最大的挑战反而不是算法本身而是 PHP 的整数运算语义和 RC6 所要求的 32 位无符号整数运算之间存在一道很有意思的鸿沟。这篇文章我会直接给你一份可运行的 PHP 版 RC6 完整源码同时把实现过程中的设计取舍、整数溢出坑、字节序问题、测试方法全部讲清楚。适合这几类人看想给学生或团队做密码学教学演示的开发者、需要在 PHP 老项目里对接一个非标准 RC6 加密接口的工程师、以及所有想看看“一个块密码到底怎么落地成代码”的好奇心选手。1. RC6 设计思路拆解从 RC5 到乘法扩散RC6 不是凭空冒出来的。它的前身 RC5 以简单著称整个加密过程只有加法、异或和循环移位三种操作但这三种操作组合起来之后在当年看来已经有了不错的强度。RC6 在 RC5 的基础上做了一次关键升级引入整数乘法。乘法能把一个 32 位字的信息更快地扩散到另一个字里去这是单纯靠异或和移位难以做到的。RC6 的典型配置也就是我来实现的这个配置分组 128 位、密钥可变16/24/32 字节都常用、20 轮迭代。1.1 基本运算与工作寄存器RC6 把 128 位明文分成四个 32 位字分别放到 A、B、C、D 四个寄存器里。加密过程中反复使用这四个寄存器做变换。核心运算只有四类32 位无符号加法等价于普通加法后截断到低 32 位。32 位无符号按位异或。循环左移和循环右移移位的位数由另一个寄存器的低 5 位决定这就是所谓的“数据依赖旋转”。32 位无符号整数乘法。其中“数据依赖旋转”是精髓移位量不是写死的而是由当前加密中间值实时计算出来的。这样等于让每一轮变换都对前面轮次的内容产生非线性依赖分析者很难通过固定移位轨迹去构造攻击。你把它想成一个旋转门门转多少圈取决于进门的人身上带着的一个随机数而不是每把锁都按同一个角度转。1.2 为什么乘法值得关注光有数据依赖旋转还不够。RC6 引入了一个非常经典的辅助函数f(x) x × (2x 1)。这个函数的结果先做循环左移 5 位得到本轮旋转量 t 或 u。它的巧妙之处在于x 和 2x 1 相乘以 2 的 32 次方为模输出的低 5 位能够相当充分地混合输入 x 的比特。也就是说哪怕输入字的某一位发生了变化经过这个乘法之后影响会迅速扩散到旋转量上进而影响其他三个寄存器的移位幅度。乘法在这里真正扮演了“搅拌器”的角色让每一轮变换不再局限于局部比特位。我有时候会用一句大白话跟同事解释RC6 是先让数据“转起来”再用乘法“搅散”最后用加法“叠上去”。三样东西缺一不可。如果我们把循环移位当成按位搬家那乘法就是把搬家路线本身也彻底打乱。1.3 密钥扩展到底在做什么RC6 需要用密钥来生成一组子密钥 S这套流程叫密钥扩展。简单说S 数组的长度是 2×轮数 4每个元素是 32 位整数加密时依次用于每轮的加法和最后的输出白化。扩展过程分两步先把用户提供的密钥字节流按小端方式装进一个 L 数组。然后从两个魔数常量出发依次累加填充出初始 S 数组。最后再拿 S 和 L 做三轮循环搅拌。这个搅拌过程确定性地把用户密钥的每一比特都扩散到所有 S 元素里。RC6 密钥扩展的设计动机之一是支持任意长度的密钥从 1 字节一直到 255 字节都可以实际使用中一般取 16、24、32 字节。这个灵活性在算法原始定义里写得很明确也是我们实现时几乎不需要改动的部分。2. PHP 移植 RC6 的四个核心难点把 RC6 的伪代码翻译成 PHP头号问题不是算法本身而是 PHP 的整数模型。RC6 在 C 语言里天然假设 unsigned int 是 32 位运算溢出后自动截断。PHP 则不同尤其在 64 位平台上PHP 的 int 是 64 位有符号整数。直接照抄 C 代码你会得到完全错误的结果。我把这类问题归成四个坑你实现时基本都会遇到。2.1 64 位有符号整数与 32 位无符号语义RC6 的全部中间值范围是 0 到 4294967295也就是 2 的 32 次方减一。PHP 的整数在 64 位环境下能表示更大的数所以中间值本身不会溢出成浮点但你必须在每个关键运算点手动做一次“截断到 32 位”的操作。最朴素也最实用的写法是 0xFFFFFFFF。比如两个 32 位数相加结果可能是 8589934590在 64 位 PHP 里是一个普通正数但我们必须把它 0xFFFFFFFF只留低 32 位这样语义才和 C 语言里的无符号加法一致。我建议在代码里封装一个 add32 和 sub32 函数而不是到处写 0xFFFFFFFF。一方面是为了可读性另一方面是避免你在某个角落漏掉截断操作导致加解密结果对不上。2.2 乘法溢出一段容易跑错的代码这是很多 PHP 移植版折戟的地方。RC6 里的 f(x) x × (2x 1)两个 32 位数相乘的最大值是 (4294967295 × 4294967295)约等于 1.84e19。这个数字在 64 位 PHP 里依然超过了 PHP_INT_MAX约 9.22e18所以 PHP 会把结果自动转成 float。float 的精度只有大约 53 位二进制尾数无法精确表示这个乘积的低 32 位拿到手的就是个已经丢精度的近似值。解决办法是把乘法拆成 16 位分段乘法。因为两个 16 位数的乘积最大是 4294836225完全落在 int64 范围内组合之后再做截断就能精确得到低 32 位。这个函数我后面会一起放进源码里。请记住不要写 $a * $b 然后直接 0xFFFFFFFF那个写法在 PHP 里是错的。2.3 循环移位的边界问题循环左移本身在 PHP 里可以用 (($x $n) | ($x (32 - $n))) 表达但有两个边界陷阱。第一个是移位量为 0 时$x 32 在 PHP 里的行为取决于运行平台容易得到诡异结果所以要在函数开头特判。第二个是 PHP 的右移是算术右移即符号位会扩展。不过因为我们每个中间值都截断成 0 到 4294967295 的正数右移不会带上符号位这里反而不需要额外处理。真正要注意的是左移之后超过 32 位的部分比如 $x 16 可能已经把高位移到 64 位整数的高位去了所以返回前必须 0xFFFFFFFF。2.4 字节序加密结果与别的语言对不上账RC6 在没有特殊说明时按小端字节序处理数据分组。也就是说明文的前 4 个字节会组成 A 寄存器的最低有效字节在前的整数值。PHP 里最方便的就是 pack/unpack 的 V 格式它表示 32 位无符号整数、小端字节序。我见过有人用 N 格式按大端处理结果加解密自洽但和所有参考实现都对不上。所以拿到任何加密模块先确认字节序约定再确认分组顺序最后才谈密钥。3. 完整源码RC6 加密类的实现与解读下面这份代码我在 64 位 PHP 7.4 / PHP 8.x 上跑过加解密往返和固定测试向量都能通过。整体结构是一个 RC6 类支持任意轮数默认走 20 轮。因为标题要求带完整源码我把代码切成几个片段并在每个片段后面解释设计意图。你可以把整个类的代码按顺序粘贴到一个 PHP 文件里直接使用。3.1 基础工具函数先看最底层的四个函数。add32、sub32、rotl32、rotr32、mul32 是整个算法的基石。?php class RC6 { private $S; private $rounds; public function __construct($key, $rounds 20) { $this-rounds $rounds; $this-S $this-expandKey($key); } private static function mask32($x) { return $x 0xFFFFFFFF; } private static function add32($a, $b) { return self::mask32($a $b); } private static function sub32($a, $b) { return self::mask32($a - $b); } private static function rotl32($x, $n) { $x self::mask32($x); $n 31; if ($n 0) { return $x; } return self::mask32(($x $n) | ($x (32 - $n))); } private static function rotr32($x, $n) { $x self::mask32($x); $n 31; if ($n 0) { return $x; } return self::mask32(($x $n) | ($x (32 - $n))); } private static function mul32($a, $b) { $a self::mask32($a); $b self::mask32($b); $a0 $a 0xFFFF; $a1 ($a 16) 0xFFFF; $b0 $b 0xFFFF; $b1 ($b 16) 0xFFFF; // 只需要低 32 位所以 a1*b1 那一项会被 2^32 整除无需参与 $low ($a0 * $b0) ((($a0 * $b1) ($a1 * $b0)) 16); return self::mask32($low); }这里的 mul32 是重点。a1 * b1 项乘以 2 的 32 次方后对低 32 位没有贡献所以可以直接扔掉。这个细节可能有点反直觉但它在模运算里是正确的。为什么要拆成 a0、a1因为两个 16 位数相乘最大值不超过 4294836225这个数在 64 位 PHP 的整数范围内可以直接参与后续加法。这是整个类里最需要认真看的一段其他函数反而都比较直观。3.2 密钥扩展实现密钥扩展严格按 RC6 标准流程走先把密钥字节流按小端规则组装成 L 数组再用两个魔数生成 S 数组最后做三轮混合搅拌。这里我把 L 的组装方式写成从后往前逐字节推进等价于把每 4 个字节按小端拼成一个 32 位整数。private function expandKey($key) { $keyLen strlen($key); $c max(1, (int)ceil($keyLen / 4)); $L array_fill(0, $c, 0); for ($i $keyLen - 1; $i 0; $i--) { $L[$i 2] self::mask32(($L[$i 2] 8) | ord($key[$i])); } $t 2 * $this-rounds 4; $P32 0xB7E15163; $Q32 0x9E3779B9; $S [0 $P32]; for ($i 1; $i $t; $i) { $S[$i] self::add32($S[$i - 1], $Q32); } $A 0; $B 0; $i 0; $j 0; $iterations 3 * max($t, $c); for ($k 0; $k $iterations; $k) { $A self::rotl32(self::add32(self::add32($S[$i], $A), $B), 3); $S[$i] $A; $B self::rotl32(self::add32(self::add32($L[$j], $A), $B), ($A $B) 31); $L[$j] $B; $i ($i 1) % $t; $j ($j 1) % $c; } return $S; }密钥扩展里的两个魔数 P32 和 Q32 是十六进制常量它们不是随便选的而是由自然数常量的十进制小数部分推导出来的。我们作为使用者不需要关心它们的来历只要保证完全一致就行。如果你把魔数写错一个字节整个算法的输出会全部错乱而且这种错误非常难排查。密钥扩展过程中的加法和移位同样要遵守 32 位截断规则。这里的 A B 被当作移位量时只需要低 5 位所以 31 即可。但作为更新 L[j] 的输入A 和 B 都需要完整的 32 位截断。我在扩张密钥时反复调用 add32 和 rotl32就是为了保证从密钥到 S 数组的每一步都和标准实现一致。3.3 加密与解密轮函数接下来是加密轮函数和解密轮函数。加密流程分为三部分预处理把 B、D 累加前两个子密钥20 轮迭代最后把 A、C 累加最后两个子密钥。解密流程就是反向执行顺序严格反推。public function encryptBlock($in) { $w unpack(V4, $in); $a $w[1]; $b $w[2]; $c $w[3]; $d $w[4]; $S $this-S; $r $this-rounds; $b self::add32($b, $S[0]); $d self::add32($d, $S[1]); for ($i 1; $i $r; $i) { $t self::rotl32(self::mul32($b, self::add32(self::mul32($b, 2), 1)), 5); $u self::rotl32(self::mul32($d, self::add32(self::mul32($d, 2), 1)), 5); $a self::rotl32(self::mask32($a ^ $t), $u 31); $a self::add32($a, $S[2 * $i]); $c self::rotl32(self::mask32($c ^ $u), $t 31); $c self::add32($c, $S[2 * $i 1]); $tmp $a; $a $b; $b $c; $c $d; $d $tmp; } $a self::add32($a, $S[2 * $r 2]); $c self::add32($c, $S[2 * $r 3]); return pack(V4, $a, $b, $c, $d); }这里最值得留意的是每轮末尾的寄存器轮换 (A, B, C, D) (B, C, D, A)。这个轮换让上一轮的输出在下一轮里换一个位置继续参与变换同时也保证了解密时多了一步反轮换的对称性。很多照着伪代码手写的人会漏掉这一句结果就是加解密自己验不出来错误。A 和 C 更新时先异或 t 或 u再做数据依赖旋转。注意异或前要把结果 mask32因为 PHP 中按位异或两个正数不会超过 32 位太多但保险起见我仍然强制截断一次。旋转量分别取 u 和 t 的低 5 位这和 RC6 标准完全一致。public function decryptBlock($in) { $w unpack(V4, $in); $a $w[1]; $b $w[2]; $c $w[3]; $d $w[4]; $S $this-S; $r $this-rounds; $c self::sub32($c, $S[2 * $r 3]); $a self::sub32($a, $S[2 * $r 2]); for ($i $r; $i 1; $i--) { $tmp $a; $a $d; $d $c; $c $b; $b $tmp; $t self::rotl32(self::mul32($b, self::add32(self::mul32($b, 2), 1)), 5); $u self::rotl32(self::mul32($d, self::add32(self::mul32($d, 2), 1)), 5); $c self::sub32($c, $S[2 * $i 1]); $c self::rotr32($c, $t 31); $c self::mask32($c ^ $u); $a self::sub32($a, $S[2 * $i]); $a self::rotr32($a, $u 31); $a self::mask32($a ^ $t); } $b self::sub32($b, $S[0]); $d self::sub32($d, $S[1]); return pack(V4, $a, $b, $c, $d); }解密轮函数里的反轮换顺序 (A, B, C, D) (D, A, B, C) 要和加密轮末尾的轮换严格互逆。你可以这样检查把加密第 i 轮的输出当成解密第 i 轮的输入先做反轮换再做反变换得到的一定是加密第 i 轮的输入。如果这个往返对不上优先检查反轮换和 rotr 的方向。解密里每个减法和减法后的异或都需要正确截断。特别要注意 C 先减去子密钥再 rotrA 也是先减去子密钥再 rotr这个顺序不能反过来。原因很简单加密时是先旋转再加密钥解密就必须先减密钥再逆旋转。3.4 数据补齐与 ECB 封装RC6 本身是分组密码一次只能处理 16 字节。如果要加密任意长度的字符串需要自己做数据补齐。我给出一个标准的 PKCS#7 补齐实现并封装成 encrypt 和 decrypt 两个方法。public function encrypt($data) { $data $this-pad($data); $result ; $len strlen($data); for ($i 0; $i $len; $i 16) { $result . $this-encryptBlock(substr($data, $i, 16)); } return $result; } public function decrypt($data) { $result ; $len strlen($data); for ($i 0; $i $len; $i 16) { $result . $this-decryptBlock(substr($data, $i, 16)); } return $this-unpad($result); } private function pad($data) { $padLen 16 - (strlen($data) % 16); return $data . str_repeat(chr($padLen), $padLen); } private function unpad($data) { $padLen ord($data[strlen($data) - 1]); return substr($data, 0, strlen($data) - $padLen); } }这样封装之后日常使用就很方便了。你只需要 new RC6($key)然后调用 encrypt($plaintext) 和 decrypt($ciphertext)。但我必须提醒一句ECB 模式不适合加密大量有规律的数据因为它会让相同的明文块产生相同的密文块。下面的测试和示例我都用 ECB 演示因为算法本身只到这里如果用于真实系统强烈建议在这个类外面套一层 CBC 或 CTR 模式至少也要加个随机 IV。4. 测试向量与正确性自检写完算法类之后第一件事不是直接拿真实数据去跑而是先验证实现对不对。这一步如果跳过后面加解密失败时你会浪费很多时间在错误的方向上排查。4.1 用固定向量核对结果我提供一个非常经典的 RC6 测试向量你可以在代码里直接断言密钥16 字节十六进制00000000000000000000000000000000明文16 字节十六进制00000000000000000000000000000000期望密文十六进制8fc3a53657356c43d98b1d5d92a5d2e6如果你的实现输出和这个不一致说明某个环节的截断、旋转方向或字节序出了问题。注意不要用只验证加解密往返的方式代替这个测试因为就算你把加密和解密写对称了也可能偏离标准 RC6 定义。固定向量是用来对齐标准的往返测试只是用来检查内部一致性的两者都很重要。4.2 加解密往返测试固定向量通过后再写一个随机数据的往返测试。我习惯生成一段随机字节串加密后解密比对原始内容是否完全一致。这个过程主要用来捕捉那些只影响特定分支的边界 bug比如某一位刚好让旋转量变成 0或者某种数据长度的 pad 结果比较特殊。$key random_bytes(32); $rc6 new RC6($key); $original random_bytes(1000); $cipher $rc6-encrypt($original); $decrypted $rc6-decrypt($cipher); var_dump(hash_equals($original, $decrypted));使用 hash_equals 比较字符串是本格的做法能避免时序侧信道问题虽然测试环境无所谓但养成好习惯没坏处。random_bytes 在 PHP 7 可以直接用不用自己造随机数。4.3 跨语言互通的额外检查项如果你的 PHP 实现要跟 Java、Python、Go 等其他语言写的 RC6 模块互通光测试向量过了还不够。我建议至少检查三件事。第一分组顺序。RC6 明文 16 字节分成 A、B、C、D 四个字字节序到底是小端还是大端必须双方确认。第二密钥扩展时 L 数组的组装方式是不是同样按小端规则把密钥字节拼成 32 位字。第三数据补齐方式。ECB 下双方必须用同一种 pad 方案PKCS#7 和 PKCS#5 在 16 字节分组下实际上是一样的但如果是其他自定义补齐就要仔细对齐。我在实际项目里遇到过一种情况双方算法和密钥完全一致但对方在加密前对明文做了 UTF-8 编码而我在 PHP 这边直接用了未经处理的原始字符串导致二进制内容不一致怎么都对不上。所以要提醒你跨语言互通不只是算法问题还涉及编码、输入输出格式和模式约定。5. 常见问题与调优经验最后这部分是实战经验。我在实现和调试 RC6 类时踩过一些坑把它们整理成速查表你遇到同类问题时可以直接对照排查。5.1 解密出现乱码的排查顺序如果解密出来的数据前面一半正常、后面一半乱码或者全部乱码按照这个顺序查一遍大多数情况下能找到问题。第一确认密钥是否一致。RC6 的密钥扩展对输入非常敏感哪怕密钥只有一比特不同S 数组会完全不一样。第二确认初始向量或模式。如果你在 ECB 基础上自己加了 CBC那每一块的解密都依赖前一块的密文任何一块传错后面的块就会全错。第三确认字节序。看看 pack 用的是 V小端还是 N大端。第四确认数据没有在传输过程中被转义。很多字符串经过 base64 或 URL 编码后再解密就会因为多出来的字符导致分组错位。排查顺序之所以这么排是因为密钥错误是最容易复现的而数据转义问题最隐蔽。如果你刚接触这块建议先用 16 字节的固定明文做单块加解密这样可以最小化变量。5.2 性能优化该快则快该稳则稳RC6 在 PHP 里的性能不会特别惊艳因为每个乘法都要走 mul32 的分段处理20 轮迭代里需要大量函数调用。如果只是做教学演示或低频接口这点开销完全不用在意。但如果要在生产环境加密大量数据我建议你做两件事。第一把热路径上的函数调用尽量内联。比如 $this-add32 这种只做一行运算的辅助方法改成直接在轮函数里写 mask32($a $b)可以节省不少函数调用开销。第二如果项目允许安装扩展考虑用 openssl 扩展支持的标准算法替代自研 RC6除非你有必须兼容老数据的硬性需求。RC6 虽然设计优秀但它没有进入 AES 标准PHP 生态里也没有官方维护的高性能扩展通常只在特定遗留系统里才会用到。5.3 什么时候不该自己写 RC6我的建议是学习算法和做加密库完全是两回事。自己写 RC6 是非常好的学习材料能帮你理解分组密码的扩散和混淆但如果你只是要在 PHP 系统里实现安全加密第一选择永远是 openssl 扩展里现成的 AES-256-GCM 或 AES-256-CBC。自研算法最大的风险不在算法本身而在模式选择、随机数生成、密钥管理和防侧信道这些外层问题上。就算你的 RC6 完全符合标准也不代表你的系统就安全了。如果你确实需要和某个老系统对接 RC6也不要自己从头到尾重新造轮子。优先找成熟语言的参考实现把密钥扩展、分组处理、模式封装这几个关键接口对齐然后在两侧用固定测试向量做交叉验证。我的习惯是先在本地写一个小工具把对方的测试向量跑通再做真实数据联调。这个习惯帮我省下了大量联调时间。回到这份 PHP 实现本身。你在阅读源码时最重要的不是记住每一行而是理解四个点为什么乘法要分段、为什么移位要处理边界、为什么每个结果都要截断到 32 位、为什么解密顺序和加密严格相反。把这四点吃透了RC6 这个算法对你来说就不再是一段黑盒代码而是一个能看到内部齿轮转动的机械模型。后续无论你去看其他密码算法还是在其他语言里重新实现一遍都会发现它们的内核逻辑其实是想通的。