
前言A 服务器上加密的数据搬到 B 服务器解不开了——这类问题在跨版本迁移时非常常见而且往往被直接归因成PHP 7.3 和 8.0 的openssl_encrypt()行为不一样。这个归因大概率是错的但它会让人朝错误的方向查很久。先澄清一个事实PHP 8.0 并没有修改openssl_encrypt()的加密算法实现。它调用的一直是 OpenSSL 库libcrypto提供的 EVP 接口。真正让加密结果不同的几乎总是下面三件事PHP 发行包所链接的 OpenSSL 库版本不同、代码里省略了某些参数依赖了隐式默认值、以及密钥和初始向量IVinitialization vector的处理不规范。需要特别注意的一个版本分界线在OpenSSL 3.0它引入了 provider 机制把大量遗留算法legacy algorithms移出了默认 provider。如果你的代码用了 DES、RC4、Blowfish 这类算法在 OpenSSL 3.0 上可能直接返回false或者只能加载 legacy provider 之后才可用。这跟 PHP 是 7.3 还是 8.0 无关跟机器上装的是哪个 OpenSSL 有关。本文先把假不一致和真不一致分开再给出一个跨版本结果稳定的加解密实现。一、第一步密文不同未必是 bug密码学里有一条基本设计原则使用随机 IV 时同一明文每次加密的密文都应该不同。所以看到7.3 和 8.0 加密同一串明文得到两段不同的密文有时这恰恰说明两边都工作正常。判断方法很简单做一次交叉解密交叉测试结果结论下一步7.3 加密 → 8.0 能解开8.0 加密 → 7.3 能解开一切正常密文不同只是因为 IV 随机无需修改一边能解另一边报错或返回 false参数或算法不一致进入第二节的逐项比对两边都能解开但解出的明文不等于原文更危险padding 或编码处理不一致优先排查 IV、编码与填充两边都能解开但内容不对是最危险的一类因为程序不会报错错误数据会一路写进数据库。二、真不一致的四个来源来源具体表现判定方式OpenSSL 库版本不同同一算法在两边表现不同甚至一边直接 false打印OPENSSL_VERSION_TEXT算法与密钥长度不匹配密钥长度不对时不同 OpenSSL 版本有的补零、有的截断、有的报错显式把密钥散列成固定长度IV 处理不规范一边用固定 IV一边用随机 IV或 IV 长度不符检查 IV 的生成与传递编码与填充不一致一边 base64 又 base64一边用OPENSSL_RAW_DATA统一走原始字节先跑这两行把环境差异这件事实钉死php -r echo PHP_VERSION, | , OPENSSL_VERSION_TEXT, PHP_EOL; # 列出可用的 aes-256 算法写法兼容 PHP 7.x 与 8.x php -r $m array_filter(openssl_get_cipher_methods(), function ($c) { return strpos($c, aes-256) ! false; }); print_r(array_values($m));两台机器如果OPENSSL_VERSION_TEXT一个显示 1.1.1 系列、一个显示 3.0 系列那么下面的差异就有了解释3.0 默认不再加载 legacy providerdes-ede3、bf-cbc、rc4这类算法在默认配置下会不可用。它们不是被 PHP 删掉的是被 OpenSSL 移走的。三、写出跨版本结果一致的代码要消除不一致唯一可靠的办法是把所有隐式默认值都变成显式参数。下面这个实现只使用最通用、跨 OpenSSL 1.1.1 与 3.0 都稳定可用的 AES-256-GCM并且显式指定密钥长度、IV 长度、认证标签tag长度。?php // Crypto.php declare(strict_types1); final class Crypto { private const CIPHER aes-256-gcm; private const KEY_BYTES 32; // aes-256 需要 32 字节密钥 private const IV_BYTES 12; // GCM 推荐 12 字节 IV private const TAG_BYTES 16; // 认证标签长度 /** * 把任意长度的口令散列成固定长度密钥避免密钥长度不匹配的歧义 */ private static function deriveKey(string $passphrase): string { return hash(sha256, $passphrase, true); } public static function encrypt(string $plain, string $passphrase): string { $key self::deriveKey($passphrase); $iv random_bytes(self::IV_BYTES); $tag ; $raw openssl_encrypt( $plain, self::CIPHER, $key, OPENSSL_RAW_DATA, // 要原始字节不要 base64 $iv, $tag, // 按引用传入函数会写入认证标签 , self::TAG_BYTES ); if ($raw false) { throw new RuntimeException(加密失败: . openssl_error_string()); } // 固定布局IV | TAG | 密文一起 base64 便于存储 return base64_encode($iv . $tag . $raw); } public static function decrypt(string $payload, string $passphrase): string { $key self::deriveKey($passphrase); $bin base64_decode($payload, true); if ($bin false || strlen($bin) self::IV_BYTES self::TAG_BYTES) { throw new RuntimeException(密文格式不合法); } $iv substr($bin, 0, self::IV_BYTES); $tag substr($bin, self::IV_BYTES, self::TAG_BYTES); $data substr($bin, self::IV_BYTES self::TAG_BYTES); $plain openssl_decrypt( $data, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag ); // GCM 的标签校验失败时这里返回 false —— 密文被改动过或密钥不对 if ($plain false) { throw new RuntimeException(解密失败密钥错误或密文已被篡改); } return $plain; } }跑一遍验证自洽性?php // crypto_demo.php declare(strict_types1); require __DIR__ . /Crypto.php; printf(PHP: %s | %s\n\n, PHP_VERSION, OPENSSL_VERSION_TEXT); $pass a-long-passphrase-from-env; $text 订单号 20240503-001金额 199.00 元; $c1 Crypto::encrypt($text, $pass); $c2 Crypto::encrypt($text, $pass); printf(密文1: %s\n, $c1); printf(密文2: %s\n, $c2); printf(两次密文相同? %s随机 IV 下应当为 false\n\n, var_export($c1 $c2, true)); printf(解密1: %s\n, Crypto::decrypt($c1, $pass)); printf(解密2: %s\n\n, Crypto::decrypt($c2, $pass)); // 用错误密钥解密应被拒绝 try { Crypto::decrypt($c1, wrong-passphrase); } catch (Throwable $e) { printf(错误密钥: %s\n, $e-getMessage()); } // 篡改密文末位应被拒绝 $tampered substr($c1, 0, -4) . AAAA; try { Crypto::decrypt($tampered, $pass); } catch (Throwable $e) { printf(篡改密文: %s\n, $e-getMessage()); }输出形如PHP: 8.0.30 | OpenSSL 1.1.1w 11 Sep 2023 密文1: 3QmS7Yt0kL5vXm2Nq8bP4TnZ...略 密文2: 9xKd2Lm4pQ7sVn1Rb6cT5WyH...略 两次密文相同? false 解密1: 订单号 20240503-001金额 199.00 元 解密2: 订单号 20240503-001金额 199.00 元 错误密钥: 解密失败密钥错误或密文已被篡改 篡改密文: 解密失败密钥错误或密文已被篡改这段代码在 PHP 7.3 和 PHP 8.0 上产出的密文格式完全相同且互相可解。原因不是运气而是它把所有会随环境变化的量都固定住了密钥长度固定 32 字节、IV 固定 12 字节且显式传入、标签长度固定 16 字节、输出统一用OPENSSL_RAW_DATA取原始字节、算法选的是两个 OpenSSL 大版本都稳定支持的 AES-256-GCM。四、存量数据怎么办新代码统一了历史数据还得能解开。这时候必须知道旧代码当初用的是什么参数。逐个确认下面五个问题算法是什么aes-128-cbc还是早已不该用的des-ede3密钥是怎么来的原文直接用还是做过 md5/sha1 派生IV 是固定的还是随机的如果固定值是多少如果随机存在密文的哪个位置输出编码是什么原始字节、base64、还是 hex填充方式是什么PKCS#7 还是零填充常见的历史坑是从mcrypt迁移过来的数据。mcrypt时代的MCRYPT_RIJNDAEL_128习惯用零填充zero padding而openssl_encrypt默认用 PKCS#7 填充两者对不齐时解出来的明文末尾会多出一串\0看起来解开了但内容不对。迁移策略只能是读旧写新解密时按旧参数解加密时按新方案写并在数据里加一个版本标记位等全部数据都迁移完再删掉旧逻辑。常见坑点1. 把密文不同当成 bug❌ 看到两次加密结果不一样就断定加密坏了跑去改算法。✅ 先用交叉解密验证能不能互相解开随机 IV 下密文必然不同这是设计而不是缺陷。2. 用 ECB 模式省掉 IV❌openssl_encrypt($data, aes-256-ecb, $key)相同明文永远得到相同密文泄漏数据模式。✅ 用 GCM 或 CBC 加随机 IVaes-256-ecb只在极少数兼容场景下才被迫使用。3. 忘记OPENSSL_RAW_DATA又做了一次 base64❌base64_encode(openssl_encrypt($d, $c, $k, 0, $iv))默认输出已经是 base64等于编码两次长度膨胀、占用翻倍。✅ 加OPENSSL_RAW_DATA拿原始字节再自己 base64 一次。4. 密钥直接用手写字符串长度不管❌openssl_encrypt($d, aes-256-cbc, mysecret, ...)密钥只有 8 字节不同 OpenSSL 版本对不足长度的处理不同补零、截断或报错结果自然对不上。✅ 统一用hash(sha256, $passphrase, true)派生出固定 32 字节密钥。5. IV 复用到同一密钥的多次加密❌ CBC 模式下把 IV 写死成常量多次加密用同一个 IV会泄漏明文差异。✅ 每次加密random_bytes()生成新 IV并把 IV 与密文一起存储。6. 忽略openssl_encrypt的false返回值❌$c openssl_encrypt(...); file_put_contents($f, base64_encode($c));加密失败时把空串写进文件数据静默丢失。✅ 每次都检查返回值失败时用openssl_error_string()取原因并抛异常。7. 用了被 OpenSSL 3.0 移出默认 provider 的算法❌des-ede3-cbc、bf-cbc、rc4在 OpenSSL 3.0 上不可用代码在 8.0 机器上直接失败。✅ 换成aes-256-gcm确实需要遗留算法时也要清楚它只能在加载 legacy provider 的环境里工作。8. 把密钥写在代码或仓库里❌private const KEY xxx;提交进 Git换机器时不一致其实是因为两边密钥真的不同。✅ 密钥从环境变量或密钥管理服务读取并确认所有环境取到的是同一份。总结现象真实原因处理方式密文不同但互相能解随机 IV正常无需处理一边解不开算法/密钥长度/编码不一致逐项显式化参数能解但内容不对填充方式不一致如零填充与 PKCS#7统一填充方案直接返回 falseOpenSSL 3.0 下算法不可用换成 AES-GCM换机器就失效链接的 OpenSSL 库版本不同打印OPENSSL_VERSION_TEXT比对遇到7.3 和 8.0 加密结果不一致正确的第一反应不是去查 PHP 的更新日志而是打印两台机器的OPENSSL_VERSION_TEXT再核对代码里每一个被省略的参数。加密代码里没有隐式默认值结果就不会随版本漂移把算法、密钥派生、IV 生成、编码方式、填充方式这五件事全部写死在代码里跨版本互解就是必然结果而不是运气。