拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP crypt()函数的用法讲解

PHP crypt()函数的用法讲解

前言


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 形式对应常量说明



两个普通字符,如rlCRYPT_STD_DES传统 DES,只取密码前 8 字节,输出 13 字符,强度早已不足

_J9..开头的 9 字符CRYPT_EXT_DES扩展 DES

$1$开头CRYPT_MD5MD5 crypt,salt 最多 8 字符,输出 34 字符

$2y$开头CRYPT_BLOWFISHbcrypt,本节重点

$5$开头CRYPT_SHA256SHA-256 crypt,salt 最多 16 字符

$6$开头CRYPT_SHA512SHA-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,但这样会引入自己的兼容性问题,普通业务不必这么做。


常见坑点



  1. ❌ 用密码自己当 salt:crypt($password, $password),想靠「算出来一样就说明对」来验证。这样每次 salt 都不同,永远对不上。


✅ 校验时把存库的完整散列当 salt:hash_equals($stored, crypt($password, $stored)),或者直接用password_verify($password, $stored)。



  1. ❌ 用==比较散列:if ($stored == crypt($input, $stored))。字符串比较不是常量时间。


✅hash_equals($stored, $computed)。



  1. ❌ 省略 salt:$h = crypt($password);。PHP 5.6~7.x 只是发E_NOTICE,容易被错误抑制掩盖;PHP 8.0 起salt变成必填参数,直接抛ArgumentCountError,脚本中断。


✅password_hash($password, PASSWORD_DEFAULT),完全不用自己拼 salt。



  1. ❌ salt 里混入 base64 的+/=/~等非 bcrypt 字母表字符,crypt()静默返回失败值,代码却当成功写库。


✅ 生成后校验:输出必须以$2y$开头且长度为 60,否则当成错误处理。



  1. ❌ 把crypt()当可逆加密,到处找「解密」办法。


✅ 它是单向散列。真要可逆,用sodium_crypto_secretbox()(PHP 7.2+ 内置)或openssl_encrypt(),并且每次都用随机 nonce / IV。



  1. ❌ 用$2a$或$2x$前缀,以为和$2y$等价。$2x$/$2y$是针对早期实现中符号扩展缺陷的修正变体,混用会导致同一密码在不同实现上算出不同结果。


✅ 新代码统一使用$2y$,或干脆交给password_hash()。



  1. ❌ 用md5($password)/sha1($password)存密码,或者用md5($salt . $password)自己「加盐」。


✅ 快哈希(MD5/SHA-1/SHA-256)在这类场景下就是错误工具,它们是设计成「快」的。存密码只能用password_hash()/password_verify()。



  1. ❌ 认为 13 字符的 DES crypt 输出仍然够安全,拿它存新密码。


✅CRYPT_STD_DES只使用密码前 8 个字符且密钥空间极小,现代机器可暴力枚举;只能用于识别老数据,不能用于新数据。


总结




关注点老写法现行推荐



生成散列crypt($pwd, $salt),自己拼 saltpassword_hash($pwd, PASSWORD_DEFAULT)

校验密码crypt($pwd, $hash) === $hashpassword_verify($pwd, $hash)

常量时间比较==hash_equals()

参数升级手工重建 saltpassword_needs_rehash()+ 重新password_hash()

算法DES / MD5 cryptbcrypt(默认)或 Argon2id(PHP 7.3+ 且编译支持)

可逆加密误用crypt()sodium_crypto_secretbox()/openssl_encrypt()+ 随机 IV



crypt()不是坏函数,但它把太多安全细节暴露给了调用者,任何一个细节写错都是静默失效。新代码直接使用password_hash()/password_verify();只有在维护老系统的存量 bcrypt 散列时,才需要理解 salt 前缀这套机制,而且理解它的最好用途就是——把数据平滑迁移出去。





返回列表