前言
「PHP 的加密函数」这个说法其实把三类完全不同的需求混在了一起:存密码、可逆地保护数据、校验数据有没有被改过。这三类需求的正确工具完全不同,选错的后果也不一样——用md5()存密码,数据库一旦泄露就等于明文泄露;把base64_encode()当加密,等于什么都没做。
还有一个高频误解:很多人以为「函数越新越强」,于是拿hash()随手算个 SHA-256 去存密码。哈希函数快是优点,但在密码场景里恰恰是缺点——攻击者可以用同样的速度爆破。密码需要的是慢哈希,也就是password_hash()。
本文按用途分四类梳理 PHP 里真正常用、且在当前 PHP 8.x 上仍然正确的加密/哈希工具:摘要函数、密码专用函数、可逆加密、完整性校验。文中会明确指出哪些老写法(mcrypt_*、md5()存密码、固定 IV)已经不能再用了。
一、摘要(哈希)函数:md5、sha1、hash 系列
摘要函数(hash function)的共同特征是单向、定长、确定性:同一个输入永远得到同一个输出,输出长度固定,理论上无法从输出反推输入。
| 函数 | 输出 | 现状 |
|---|
md5($str) | 32 位十六进制 | 碰撞已被实际构造出来,不可用于安全场景 |
sha1($str) | 40 位十六进制 | 同样已被实际碰撞,不可用于安全场景 |
hash('sha256', $str) | 64 位十六进制 | 目前仍安全,推荐 |
hash('sha256', $str, true) | 32 字节原始二进制 | 第三个参数取原始字节,常用于派生密钥 |
hash_hmac('sha256', $data, $key) | 64 位十六进制 | 带密钥的摘要,用于完整性校验 |
hash()支持的算法列表由hash_algos()返回,不要凭记忆写算法名。md5()和sha1()这两个函数本身没有被移除,在 PHP 8 里照常可调用——问题不在函数,而在于它们的抗碰撞性已经破产。它们现在的合理用途只剩「非安全的去重标识」这类场景(比如给缓存生成一个 key),而不是任何与安全有关的判断。
需要强调:不要用 md5/sha1 存密码,也不要用它们做签名。签名和完整性校验请用hash_hmac()。
二、密码专用:password_hash 与 password_verify
密码存储是唯一一个「必须慢」的场景。PHP 从 5.5 起提供了专门的 API:
password_hash(string $password, string|int|null $algo = PASSWORD_DEFAULT, array $options = []): stringpassword_verify(string $password, string $hash): boolpassword_needs_rehash(string $hash, string|int|null $algo, array $options = []): bool
使用要点:
PASSWORD_DEFAULT当前对应 bcrypt,它的设计意图是随 PHP 版本演进,因此输出长度可能变化。数据库列请开到 255 字符,不要按「bcrypt 是 60 字符」写死成char(60)。- bcrypt 只处理前 72 字节,超出部分被忽略。如果你的密码允许超长(例如用户提交 base64 后的材料),要先自行处理长度;另外 bcrypt 遇到空字节会截断。
- 不要自己传 salt。
salt选项在 PHP 7.0 起废弃、PHP 8.0 起被忽略,函数自己会生成随机盐并写进哈希串。 - 加 pepper(服务端密钥)要用
hash_hmac()预处理,而不是自己拼字符串后加密。password_hash()本身没有 pepper 参数。 cost要实测调优。官方建议调到单次交互式登录耗时在数百毫秒以内,具体数值和机器性能有关,请在自己服务器上测。- 换算法靠
password_needs_rehash(),登录校验通过后判断一次,需要就重新password_hash()存回。 PASSWORD_ARGON2ID更好,但需要在编译 PHP 时带上 Argon2 支持,能否使用取决于你的构建。
<?php // 适用于 PHP 7.4+
$hash = password_hash($plain, PASSWORD_DEFAULT);
// $hash 形如 $2y$10$...(60 字符),盐和 cost 都编码在里面
if (password_verify($input, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$newHash = password_hash($input, PASSWORD_DEFAULT);
// 更新数据库
}
}这里不要写if (password_verify(...) == true)——返回值就是 bool,直接判断即可;更要避免用==去比较两个哈希串,那既没意义也不安全(哈希里有盐,每次结果都不同)。
三、可逆加密:openssl 与 sodium
需要还原原文时用可逆加密。当前有两个选择:openssl_*(AES 系列)和sodium_*(PHP 7.2 起内置,libsodium)。
推荐AES-256-GCM,因为它是 AEAD(带关联数据的认证加密):一次调用同时给出密文和认证标签,天然防篡改。
<?php // 适用于 PHP 7.4+
$key = random_bytes(32); // 实际项目里从配置/密钥管理读取,不要每次随机
$nonce = random_bytes(12); // GCM 推荐 12 字节,必须每次不同
$tag = '';
$cipher = openssl_encrypt('机密数据', 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
$blob = base64_encode($nonce . $tag . $cipher);
// 解密
$raw = base64_decode($blob, true);
$nonce = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipher = substr($raw, 28);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);注意$tag是引用传参,加密时由openssl_encrypt()写入,解密时要原样传回去;标签不匹配时openssl_decrypt()返回false。
Sodium 路线的写法更直白,sodium_crypto_secretbox()返回的密文已把 16 字节 MAC 前置:
<?php // 适用于 PHP 7.2+
$key = sodium_crypto_secretbox_keygen();
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ct = sodium_crypto_secretbox('机密数据', $nonce, $key);
$blob = base64_encode($nonce . $ct);
$raw = base64_decode($blob, true);
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open(substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), $nonce, $key);三条铁律:nonce/IV 每次随机且不重复、密钥来自配置而不是代码里的固定字符串、加密不等于防篡改(CBC 模式要另外配 HMAC,GCM 和 secretbox 则自带)。
另外必须点名的两件事:
mcrypt_*在 PHP 7.2 废弃、7.2 起需 PECL 安装,已不适合新项目。它的填充处理、IV 处理历史上出过不少问题,老代码迁移到openssl_*或sodium_*是必选项。base64_encode()是编码不是加密。它只是把二进制映射成可打印字符,任何人不需密钥就能base64_decode()还原。它和加密的正确关系是「加密之后,为了放进文本通道而编码一层」。
四、完整性校验:hash_hmac 与 hash_equals
当你不需要保密、只需要确认「数据没被改过」时(比如回调签名、下载链接、Cookie 签名),用 HMAC:
<?php // 适用于 PHP 7.4+
$data = 'uid=42&expire=1893456000';
$key = random_bytes(32);
$sig = hash_hmac('sha256', $data, $key); // 十六进制字符串
$sig = hash_hmac('sha256', $data, $key, true); // 32 字节原始二进制
// 校验
$expect = hash_hmac('sha256', $data, $key, true);
if (!hash_equals($expect, $receivedSig)) {
throw new RuntimeException('签名不匹配');
}两个关键点:
- 不要写
md5($data . $key)。这类「拼接后摘要」的构造存在长度扩展攻击面,而且输入边界不清($data和$key交界处的歧义可以用不同组合构造出同样的拼接结果)。HMAC 是为此专门设计的结构。 - 比较签名必须用
hash_equals()。==和===都会在首个不同字节处短路返回,比较耗时与相同前缀长度相关,理论上可用于逐字节猜测签名。
常见坑点
- ❌ 用
md5($password)或sha1($password)存用户密码,理由是「反正不可逆」。 ✅ 用password_hash($password, PASSWORD_DEFAULT),校验用password_verify();需要换算法时用password_needs_rehash()。 - ❌ 认为
hash('sha256', $password)比md5()强所以可以用来存密码。 ✅ 通用哈希函数太快,正是密码场景的缺点;密码必须用专门的慢哈希。 - ❌ 把
base64_encode()当加密函数,甚至说「Base64 加密后无法阅读」。 ✅ Base64 是编码,用base64_decode()即可还原;保密必须靠真正的加密算法。 - ❌ 加密时用固定的 IV/nonce,例如
str_repeat("\0", 16)或写死的字符串。 ✅ 用random_bytes()生成随机 IV/nonce,并随密文一起存储;GCM 下 nonce 复用会导致密钥流复用,后果严重。 - ❌ 用
mt_rand()、uniqid()生成盐、令牌或 IV。 ✅ 这些不是密码学安全随机源,用random_int()/random_bytes()(PHP 7.0+)。 - ❌ 用
==比较签名或 MAC,写成if ($sig == $expect)。 ✅ 用hash_equals($expect, $sig),参数顺序是先已知值、后待校验值。 - ❌ 在新项目里继续使用
mcrypt_encrypt()/mcrypt_decrypt()。 ✅mcrypt自 PHP 7.2 废弃、7.2 起需 PECL,改用openssl_encrypt()或sodium_crypto_secretbox()。 - ❌ 认为加密后数据就「完整可信」了,直接把明文当权威数据。 ✅ CBC 等非 AEAD 模式不防篡改,要另配
hash_hmac();AES-GCM 与 secretbox 自带认证,但解密失败必须当异常处理而不是当空字符串。
总结
| 用途 | 正确工具 | 明确不要用 |
|---|
| 存用户密码 | password_hash()/password_verify()/password_needs_rehash() | md5()、sha1()、hash()、自定义加密 |
| 可逆加密 | openssl_encrypt('aes-256-gcm', ...)或sodium_crypto_secretbox() | mcrypt_*、固定 IV、ECB 模式 |
| 完整性 / 签名 | hash_hmac()+hash_equals() | md5($data.$key)、==比较 |
| 内容指纹(非安全) | hash('sha256', $data)、hash_file() | md5()用于任何安全判断 |
| 随机数 | random_int()/random_bytes() | rand()、mt_rand()、uniqid() |
| 编码 | base64_encode()/bin2hex()/urlencode() | 把它们当成加密 |
一句话结论:先问「我要解决的是保密、是防篡改、还是密码存储」,再选函数。密码走password_hash(),可逆加密走openssl_*或sodium_*且必带随机 IV,完整性走hash_hmac()加hash_equals(),而md5()、sha1()、base64_encode()在现代 PHP 安全场景里都没有位置。