前言
crypt()是 PHP 里历史最悠久的字符串函数之一,它直接来自 C 标准库的crypt(3),被设计出来只为一件事:把用户口令变成一段不可逆的散列值,写进类似 Unix 口令文件那样的存储里。
围绕它有两个非常普遍的误解。第一个是把crypt()当成「加密函数」,以为加密之后还能解密回来——它不是加密,而是单向散列(one-way hashing):没有密钥,也没有逆运算,salt 只影响「同一个密码得到不同的散列」以及「一次散列要花多少计算量」。第二个误解是把它当成今天存密码的推荐做法——这也是错的,PHP 5.5 起提供的password_hash()/password_verify()已经完全覆盖了crypt()的用途,而且替你把 salt 生成、算法前缀、失败检测都处理好了。
本文要讲清三件事:crypt()的签名以及 salt 前缀如何决定算法、PHP 8.0 对它的行为改动、以及在必须面对老代码和老数据时怎么安全地过渡到password_hash()。
一、签名与 salt 前缀决定算法
crypt(string $string, string $salt): string注意第三个事实:crypt()没有「算法」参数。用哪个算法,完全由salt字符串的开头几个字符决定。这是 Unix 口令文件格式留下的设计,也是所有初学困惑的根源。
| salt 形式 | 对应常量 | 说明 |
|---|
两个普通字符,如rl | CRYPT_STD_DES | 传统 DES,只取密码前 8 字节,输出 13 字符,强度早已不足 |
_J9..开头的 9 字符 | CRYPT_EXT_DES | 扩展 DES |
$1$开头 | CRYPT_MD5 | MD5 crypt,salt 最多 8 字符,输出 34 字符 |
$2y$开头 | CRYPT_BLOWFISH | bcrypt,本节重点 |
$5$开头 | CRYPT_SHA256 | SHA-256 crypt,salt 最多 16 字符 |
$6$开头 | CRYPT_SHA512 | SHA-512 crypt,salt 最多 16 字符 |
这些常量不是「配置项」,而是探测结果:它们只有在当前系统的 libcrypt 支持该算法时才被定义。所以判断某个算法能不能用,必须写成:
<?php // 适用于 PHP 7.0+
// 常量只在系统支持时才存在,直接用会报 undefined constant
if (defined('CRYPT_BLOWFISH') && CRYPT_BLOWFISH) {
echo "bcrypt 可用\n";
}二、bcrypt 的 salt 长什么样
bcrypt 的 salt 不是「随便一串字符」,它有严格格式,拼错了crypt()会返回一个失败值而不是抛出异常——这正是最容易造成「密码存进去了但谁也登不上」的地方。
格式是:$2y$+ 两位 cost +$+ 22 个来自./A-Za-z0-9的字符。总共 29 个字符,输出固定 60 个字符。
<?php // 适用于 PHP 8.0+
// 生成一个符合 bcrypt 规范的 salt
$cost = 10;
$salt = sprintf('$2y$%02d$', $cost)
. substr(strtr(base64_encode(random_bytes(16)), '+', '.'), 0, 22);
$hash = crypt('correct horse battery staple', $salt);
// 校验格式:bcrypt 的输出一定是 60 个字符,且以 $2y$ 开头
var_dump(strlen($hash)); // int(60)
var_dump(str_starts_with($hash, '$2y$')); // bool(true)
// 校验密码:把「已存库的完整散列」当成 salt 再算一次
$ok = hash_equals($hash, crypt('correct horse battery staple', $hash));
var_dump($ok); // bool(true)
var_dump(hash_equals($hash, crypt('wrong', $hash))); // bool(false)两个细节值得说明。第一,random_bytes()在 PHP 7.0+ 才可用,它拿不到足够熵时会抛Exception,这比mt_rand()拼 salt 安全得多(mt_rand()是伪随机数,绝不可用于安全场景)。第二,比较必须用hash_equals()而不是==或===,因为字符串比较会提前返回,理论上可被时序攻击(timing attack)逐字节试探。
三、PHP 8.0 的行为变化与失败返回值
crypt()的失败信号非常隐蔽。手册的说明是:失败时返回一个短于 13 个字符、且保证与该 salt 不同的字符串。也就是说,它不会返回false,也不会抛异常,你拿到的仍然是一个「看起来像字符串」的东西。
<?php // 适用于 PHP 8.0+
// 故意给一个不合法的 salt(bcrypt 前缀但 salt 太短)
$bad = crypt('password', '$2y$10$tooshort');
var_dump($bad); // 不是 60 字符的合法散列
var_dump(strlen($bad) < 13); // 失败判据之一版本上要特别注意:
- PHP 5.6 起,省略
salt参数会触发E_NOTICE安全警告(是 notice,不是E_DEPRECATED——手册的 changelog 写的是「Raise E_NOTICE security warning if salt is omitted」)。 - PHP 8.0 起,
salt参数不再可以省略,必须显式传入。此时它已经是签名里的必填参数,漏传不再是警告,而是直接抛ArgumentCountError(crypt() expects exactly 2 parameters, 1 given)。老代码里crypt($password)这种单参数写法在 PHP 8 上会中断执行,不能照抄。 mcrypt_*系列是另一回事:它在 PHP 7.2 被废弃、7.2 之后需要 PECL 安装,已不适合新项目。但它和crypt()无关——crypt()一直属于标准库。
四、为什么新代码应该直接写 password_hash()
把上面几节的内容合起来看,crypt()要求调用者自己负责 salt 生成、格式拼接、失败检测、常量时间比较——任何一步写错都可能是「静默失效」的。password_hash()把这一整套封装掉了:
<?php // 适用于 PHP 7.4+(PASSWORD_DEFAULT 推荐用 PHP 8.0+ 的默认 bcrypt)
$hash = password_hash($password, PASSWORD_DEFAULT);
// 校验:password_verify() 会自动识别 $2y$ / $argon2i$ 等前缀
if (password_verify($password, $hash)) {
echo "密码正确\n";
}
// 参数升级(例如以后把 cost 调高)后,用 needs_rehash 检测
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
// 此处应把 $newHash 写回数据库
}
// 想用 Argon2id 需要 PHP 7.3+ 且编译时带 Argon2 支持
if (defined('PASSWORD_ARGON2ID')) {
$argon = password_hash($password, PASSWORD_ARGON2ID);
}还有一个实用的过渡点:password_verify()可以校验crypt()生成的 bcrypt 散列,因为两者用的是同一套格式。所以老系统迁移时,不必逼所有用户立刻改密码——先用password_verify()接管校验,登录成功时判断password_needs_rehash(),顺手把散列升级成password_hash()的输出即可。
另外要澄清一个容易误解的点:bcrypt 只看密码的前 72 字节,超出部分被直接忽略。这是算法本身的限制,password_hash()用 bcrypt 时也受同样约束;如果确实需要超长口令,得先用hash('sha256', $pwd, true)之类做一次预处理再交给 bcrypt,但这样会引入自己的兼容性问题,普通业务不必这么做。
常见坑点
- ❌ 用密码自己当 salt:
crypt($password, $password),想靠「算出来一样就说明对」来验证。这样每次 salt 都不同,永远对不上。
✅ 校验时把存库的完整散列当 salt:hash_equals($stored, crypt($password, $stored)),或者直接用password_verify($password, $stored)。
- ❌ 用
==比较散列:if ($stored == crypt($input, $stored))。字符串比较不是常量时间。
✅hash_equals($stored, $computed)。
- ❌ 省略 salt:
$h = crypt($password);。PHP 5.6~7.x 只是发E_NOTICE,容易被错误抑制掩盖;PHP 8.0 起salt变成必填参数,直接抛ArgumentCountError,脚本中断。
✅password_hash($password, PASSWORD_DEFAULT),完全不用自己拼 salt。
- ❌ salt 里混入 base64 的
+/=/~等非 bcrypt 字母表字符,crypt()静默返回失败值,代码却当成功写库。
✅ 生成后校验:输出必须以$2y$开头且长度为 60,否则当成错误处理。
- ❌ 把
crypt()当可逆加密,到处找「解密」办法。
✅ 它是单向散列。真要可逆,用sodium_crypto_secretbox()(PHP 7.2+ 内置)或openssl_encrypt(),并且每次都用随机 nonce / IV。
- ❌ 用
$2a$或$2x$前缀,以为和$2y$等价。$2x$/$2y$是针对早期实现中符号扩展缺陷的修正变体,混用会导致同一密码在不同实现上算出不同结果。
✅ 新代码统一使用$2y$,或干脆交给password_hash()。
- ❌ 用
md5($password)/sha1($password)存密码,或者用md5($salt . $password)自己「加盐」。
✅ 快哈希(MD5/SHA-1/SHA-256)在这类场景下就是错误工具,它们是设计成「快」的。存密码只能用password_hash()/password_verify()。
- ❌ 认为 13 字符的 DES crypt 输出仍然够安全,拿它存新密码。
✅CRYPT_STD_DES只使用密码前 8 个字符且密钥空间极小,现代机器可暴力枚举;只能用于识别老数据,不能用于新数据。
总结
| 关注点 | 老写法 | 现行推荐 |
|---|
| 生成散列 | crypt($pwd, $salt),自己拼 salt | password_hash($pwd, PASSWORD_DEFAULT) |
| 校验密码 | crypt($pwd, $hash) === $hash | password_verify($pwd, $hash) |
| 常量时间比较 | == | hash_equals() |
| 参数升级 | 手工重建 salt | password_needs_rehash()+ 重新password_hash() |
| 算法 | DES / MD5 crypt | bcrypt(默认)或 Argon2id(PHP 7.3+ 且编译支持) |
| 可逆加密 | 误用crypt() | sodium_crypto_secretbox()/openssl_encrypt()+ 随机 IV |
crypt()不是坏函数,但它把太多安全细节暴露给了调用者,任何一个细节写错都是静默失效。新代码直接使用password_hash()/password_verify();只有在维护老系统的存量 bcrypt 散列时,才需要理解 salt 前缀这套机制,而且理解它的最好用途就是——把数据平滑迁移出去。