PHP 语法进阶 20|密码、随机数与加密:三条不能越过的线

第一条线:密码只能"哈希",不能"加密"

<?php
// ✗ 这三种都别用
$bad1 = md5($password);
$bad2 = sha1($password);
$bad3 = hash('sha256', $password . "固定盐");

问题不在"哈希"本身,而在速度。MD5 一秒能算几亿次,SHA-256 也是。拿到数据库后,用 GPU 跑字典,八位数字密码几分钟就全出来了。

密码哈希函数的设计目标恰恰是"慢"。PHP 内置了正确的实现:

<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
// 输出类似:$2y$10$wLgT9m... (bcrypt,cost=10)

PASSWORD_DEFAULT 目前是 bcrypt,未来 PHP 升级时可能切到 argon2。用 PASSWORD_DEFAULT 意味着"自动跟随 PHP 的推荐算法",这是最省心的选择。

想显式指定:

<?php
// bcrypt,cost 越高越慢(默认 10,每 +1 耗时翻倍)
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Argon2id(需要 PHP 编译时带 libargon2,7.2+)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,   // 64 MB
    'time_cost'   => 4,
    'threads'     => 2,
]);

Argon2id 是当前最推荐的算法(抗 GPU、抗侧信道),bcrypt 依然安全可用。选哪个看环境是否支持。

校验

<?php
if (password_verify($input, $storedHash)) {
    // 密码正确
}

password_verify 会从哈希串里读出盐和参数,不需要你额外存盐。哈希串自带全部信息,这是它比"md5 + 自己拼盐"高明的地方。

升级算法用 password_needs_rehash

<?php
if (password_verify($input, $row['password'])) {
    if (password_needs_rehash($row['password'], PASSWORD_DEFAULT, ['cost' => 12])) {
        $newHash = password_hash($input, PASSWORD_DEFAULT, ['cost' => 12]);
        // UPDATE users SET password = ? WHERE id = ?
    }
    // 登录成功
}

这段代码实现了"用户下次登录时自动升级哈希强度"。调高 cost、换算法,都不用强制所有人改密码。

bcrypt 的 72 字节限制

<?php
$long = str_repeat('a', 100);
var_dump(password_verify($long, password_hash($long, PASSWORD_BCRYPT)));   // true
// 第 73 个字符之后被忽略——但验证还是通过,因为两边都截断了

bcrypt 只看前 72 个字节。超长密码的尾部是无效的。标准做法是先做一次摘要:

<?php
$prepared = base64_encode(hash('sha384', $password, true));   // 固定 64 字节
$hash = password_hash($prepared, PASSWORD_DEFAULT);
// 校验时同样先 prepare

这样既不丢熵,也不会撞上限。不过对于普通业务,直接限制密码长度 ≤ 72 字节更简单。

第二条线:随机数必须用密码学安全的源

<?php
// ✗ 全部不安全
rand(1, 100);
mt_rand(1, 100);
uniqid();                 // 基于微秒时间
array_rand($arr);
str_shuffle("abcdef");    // 内部用 rand

这些函数的种子可预测,输出可被推算。用于 token、验证码、密码重置链接、抽奖、优惠券码,全部不合格。

<?php
// ✓ 密码学安全
$bytes = random_bytes(32);              // 32 字节随机
$token = bin2hex($bytes);               // 64 位十六进制字符串

$code = random_int(100000, 999999);     // 6 位数字验证码

random_bytes() 在底层调用 getrandom() / /dev/urandom,失败会抛异常(不会静默返回弱随机)。

判断标准:"如果攻击者能猜到这个值,会造成损失吗?" 会,就必须用 random_bytes / random_int。

生成 URL 安全的令牌

<?php
function urlSafeToken(int $bytes = 32): string
{
    return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
}

这种格式可以直接放进 URL、Cookie、JSON 里,不用再转义。

第三条线:比较敏感值要用 hash_equals

<?php
// ✗ 危险的比较
if ($inputToken === $storedToken) {
    // ...
}

普通字符串比较是逐字节短路的:第 1 位不同就立刻返回。攻击者可以通过测量响应时间,一位一位地试出正确值——这是"时序攻击(timing attack)"。

<?php
// ✓ 常数时间比较
if (hash_equals($storedToken, $inputToken)) {
    // ...
}

hash_equals() 无论内容差多少,耗时都一样。所有令牌、签名、HMAC、API Key 的比较都必须用它。

注意参数顺序:hash_equals($known, $user),第一个是你自己的值。虽然函数本身对顺序不敏感(长度不同会直接返回 false),保持这个习惯更清晰。

HMAC:签名的正确做法

<?php
function makeToken(array $claims, string $secretKey): string
{
    $payload = json_encode($claims, JSON_UNESCAPED_UNICODE);
    $sig = hash_hmac('sha256', $payload, $secretKey, true);

    return rtrim(strtr(base64_encode($payload . '.' . $sig), '+/', '-_'), '=');
}

拆解校验(用 strrpos 从右往保守找分隔符,避免 payload 里的点干扰):

<?php
function verifyToken(string $token, string $secretKey): ?array
{
    $raw = base64_decode(strtr($token, '-_', '+/'), true);
    if ($raw === false) return null;

    $pos = strrpos($raw, '.');
    if ($pos === false) return null;

    $payload = substr($raw, 0, $pos);
    $sig     = substr($raw, $pos + 1);

    // 关键:常数时间比对
    if (!hash_equals(hash_hmac('sha256', $payload, $secretKey, true), $sig)) {
        return null;
    }

    $data = json_decode($payload, true);
    if (!is_array($data) || ($data['exp'] ?? 0) < time()) {
        return null;
    }
    return $data;
}

先验签,再解析内容。顺序反了(先 json_decode 再用字段)就等于处理了未经验证的数据。

对称加密:只在必要时用

绝大多数场景用不到加密——密码用哈希,敏感字段该做的是"最小化存储"而不是"加密后存"。但确实需要可逆时:

<?php
function encryptString(string $plain, string $key): string
{
    $cipher = 'aes-256-gcm';
    $iv  = random_bytes(12);          // GCM 的 nonce,12 字节
    $tag = '';

    $ct = openssl_encrypt($plain, $cipher, $key, OPENSSL_RAW_DATA, $iv, $tag);
    if ($ct === false) {
        throw new RuntimeException("加密失败");
    }

    return base64_encode($iv . $tag . $ct);        // 把 iv 和 tag 一起带出去
}

function decryptString(string $encoded, string $key): string
{
    $cipher = 'aes-256-gcm';
    $raw = base64_decode($encoded, true);
    if ($raw === false || strlen($raw) < 28) {
        throw new RuntimeException("密文格式错误");
    }

    $iv  = substr($raw, 0, 12);
    $tag = substr($raw, 12, 16);
    $ct  = substr($raw, 28);

    $plain = openssl_decrypt($ct, $cipher, $key, OPENSSL_RAW_DATA, $iv, $tag);
    if ($plain === false) {
        throw new RuntimeException("解密失败(可能被篡改或密钥不对)");
    }
    return $plain;
}

四条必须遵守的规则:

  1. 用 AEAD 模式(aes-256-gcm、chacha20-poly1305)。它自带完整性校验,密文被改一个字节就会解密失败。不要用 ECB(相同明文块产生相同密文,等于泄露结构),CBC 也建议避开(需要额外的 MAC 防篡改)。
  2. 每次加密都用新的随机 IV/nonce。固定 IV 会让 GCM 的安全性彻底崩溃(同一个 key + 同一个 nonce 重复使用,能恢复出密钥流)。
  3. 密钥 32 字节,来自安全随机源,存在环境变量或密钥管理系统里。不要硬编码在代码里,不要提交进 Git,不要和密文存在同一个库里。
  4. IV 不需要保密(可以跟密文一起存),密钥绝对不能。
<?php
// 密钥从环境变量读,启动时校验长度
$key = base64_decode(getenv('APP_ENCRYPTION_KEY') ?: '', true);
if ($key === false || strlen($key) !== 32) {
    throw new RuntimeException("APP_ENCRYPTION_KEY 必须是 base64 的 32 字节");
}

密码重置流程的正确实现

这是"随机数 + 哈希 + 一次性"的综合应用:

<?php
// 1) 生成
$rawToken = bin2hex(random_bytes(32));           // 发给用户
$hash     = hash('sha256', $rawToken);           // 存库

$pdo->prepare("UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?")
    ->execute([$hash, date("Y-m-d H:i:s", time() + 3600), $uid]);

// 2) 校验
$hash = hash('sha256', $_GET['token'] ?? '');
$st = $pdo->prepare("SELECT id FROM users WHERE reset_token = ? AND reset_expires > NOW()");
$st->execute([$hash]);

// 3) 用后立即作废
$pdo->prepare("UPDATE users SET reset_token = NULL WHERE id = ?")->execute([$id]);

要点:

  • 库里只存 token 的哈希,不存明文。数据库泄露也无法直接用来重置密码。
  • 一次性,用过立刻清空。
  • 有时效,通常 30~60 分钟。
  • 不要在邮件里同时给出用户名和密码。

常见的安全错误清单

  • 用 md5/sha1/sha256 直接存密码。
  • 自己实现"加盐"逻辑,或所有用户共用一个固定盐。
  • 用 rand()/mt_rand() 生成 token。
  • 用 === 比较令牌,留下时序攻击空间。
  • 加密用 ECB 模式,或固定 IV。
  • 密钥写死在代码里 / 提交到 Git。
  • openssl_encrypt 不传 $tag,等于放弃了完整性校验。
  • 密码重置 token 存明文、可重复使用、无过期。
  • 把"加密"当成"哈希"用(加密后还能解密出来,密码就不该能还原)。

一句话总结这三条线

  1. 密码:password_hash + password_verify,用 password_needs_rehash 平滑升级。
  2. 随机:random_bytes / random_int,别碰 rand 家族。
  3. 比较与签名:hash_equals + hash_hmac;真需要加密就用 AEAD 模式 + 随机 IV + 密钥放环境。