分析 WordPress `wp_check_password()` 函数的源码:如何兼容旧版密码哈希,以保证平滑升级。

WordPress密码兼容:一次穿越时空的密码学之旅

大家好,我是你们今天的密码考古学家,咳咳,讲师。今天咱们不挖坟,挖的是WordPress密码系统的历史遗迹,看看wp_check_password()这个函数是如何做到“老少通吃”,既能理解远古时代的密码,又能驾驭新时代的加密技术,保证用户升级体验如丝般顺滑的。

密码学:WordPress的阿喀琉斯之踵

在开始之前,我们先达成一个共识:密码安全是网站安全的基石。如果密码被破解,再精妙的设计、再强大的服务器,都如同纸糊的房子,不堪一击。WordPress深知这一点,所以在密码处理上一直小心翼翼,步步为营。

但问题来了,技术在进步,黑客也在进化。早期的密码加密算法可能在现在看来漏洞百出。为了应对这种变化,WordPress不得不升级密码加密方式。但升级就意味着兼容性问题:如果直接切换到新的加密方式,所有老用户的密码都将失效,用户体验瞬间跌入谷底,这显然是不可接受的。

所以,wp_check_password()应运而生,它的核心任务就是:在保证安全的前提下,兼容旧版本的密码哈希算法,让用户无痛升级。

wp_check_password():密码界的“百变星君”

wp_check_password()函数的源码位于wp-includes/pluggable.php文件中。我们先来看一下它的基本结构(为了方便理解,我稍微简化了一下,去掉了部分debug代码):

function wp_check_password( $password, $hash, $user_id = '' ) {
    global $wp_hasher;

    /**
     * Filters whether to short-circuit the password check.
     *
     * @since 3.5.0
     *
     * @param bool   $pre_check Whether to short-circuit the password check. Default false.
     * @param string $password  The user-supplied password to check.
     * @param string $hash      A hash (usually retrieved from the database) to check the password against.
     * @param string $user_id   The user ID.
     */
    $check = apply_filters( 'check_password', false, $password, $hash, $user_id );

    if ( $check ) {
        return apply_filters( 'check_password_result', $check, $password, $hash, $user_id );
    }

    if ( strpos( $hash, '$P$' ) === 0 ) {
        // Old WordPress pre-2.5 hashing.
        $portable_hashes = apply_filters( 'portable_hashes', true );

        if ( ! $portable_hashes ) {
            return false;
        }

        if ( ! class_exists( 'PasswordHash' ) ) {
            require_once ABSPATH . WPINC . '/class-phpass.php';
        }

        $wp_hasher = new PasswordHash( 8, true );

        $check = $wp_hasher->CheckPassword( $password, $hash );

        if ( $check ) {
            return apply_filters( 'check_password_result', true, $password, $hash, $user_id );
        }

        return apply_filters( 'check_password_result', false, $password, $hash, $user_id );
    }

    if ( strpos( $hash, '$H$' ) === 0 ) {
        // Old PHPass, pre-2.5, and not supported.
        return apply_filters( 'check_password_result', false, $password, $hash, $user_id );
    }

    if ( empty( $wp_hasher ) ) {
        require_once ABSPATH . WPINC . '/class-phpass.php';
        $wp_hasher = new PasswordHash( 8, true );
    }

    $check = $wp_hasher->CheckPassword( $password, $hash );

    if ( $check ) {
        return apply_filters( 'check_password_result', true, $password, $hash, $user_id );
    }

    return apply_filters( 'check_password_result', false, $password, $hash, $user_id );
}

这个函数接收三个参数:

  • $password: 用户输入的明文密码。
  • $hash: 存储在数据库中的密码哈希值。
  • $user_id: 用户ID(可选,用于某些插件)。

接下来,我们逐行分析这个函数的工作流程:

  1. 过滤器(Filter)优先:

    $check = apply_filters( 'check_password', false, $password, $hash, $user_id );
    if ( $check ) {
        return apply_filters( 'check_password_result', $check, $password, $hash, $user_id );
    }

    WordPress的强大之处在于其插件机制。这里首先通过apply_filters('check_password', ...),允许其他插件“劫持”密码验证过程。如果某个插件返回了truefalse,表示它已经完成了验证,wp_check_password()会直接返回插件的结果。这为开发者提供了极大的灵活性,例如可以使用外部认证服务,或者实现自定义的密码策略。

  2. 识别远古时代的密码($P$):

    if ( strpos( $hash, '$P$' ) === 0 ) {
        // Old WordPress pre-2.5 hashing.
        $portable_hashes = apply_filters( 'portable_hashes', true );
    
        if ( ! $portable_hashes ) {
            return false;
        }
    
        if ( ! class_exists( 'PasswordHash' ) ) {
            require_once ABSPATH . WPINC . '/class-phpass.php';
        }
    
        $wp_hasher = new PasswordHash( 8, true );
    
        $check = $wp_hasher->CheckPassword( $password, $hash );
    
        if ( $check ) {
            return apply_filters( 'check_password_result', true, $password, $hash, $user_id );
        }
    
        return apply_filters( 'check_password_result', false, $password, $hash, $user_id );
    }

    这里是关键所在。WordPress 2.5之前的版本使用了一种非常古老的、基于DES的哈希算法,其哈希值以$P$开头。这段代码检测到这种情况后,会加载PasswordHash类(位于wp-includes/class-phpass.php),并使用它来验证密码。

    • $portable_hashes 过滤器: 这是一个安全开关。 早期DES实现因性能问题,依赖于特定硬件。 portable_hashes 过滤器允许管理员禁用对这些旧哈希的支持,以防止潜在的安全风险。 如果禁用,那么包含 $P$ 的哈希将直接验证失败。

    • PasswordHash类: 这个类负责处理旧的密码哈希。 它包含了必要的逻辑来解密和验证这些哈希。

  3. 识别更远古的密码($H$):

    if ( strpos( $hash, '$H$' ) === 0 ) {
        // Old PHPass, pre-2.5, and not supported.
        return apply_filters( 'check_password_result', false, $password, $hash, $user_id );
    }

    $P$更古老的哈希以$H$开头。由于安全性太差,WordPress直接放弃了对这种哈希的支持,直接返回false,拒绝验证。这意味着使用这种密码的用户必须重置密码。

  4. 现代密码验证:

    if ( empty( $wp_hasher ) ) {
        require_once ABSPATH . WPINC . '/class-phpass.php';
        $wp_hasher = new PasswordHash( 8, true );
    }
    
    $check = $wp_hasher->CheckPassword( $password, $hash );
    
    if ( $check ) {
        return apply_filters( 'check_password_result', true, $password, $hash, $user_id );
    }
    
    return apply_filters( 'check_password_result', false, $password, $hash, $user_id );

    如果密码哈希既不是$P$也不是$H$,那么就认为它是使用较新的bcrypt算法生成的。这段代码会再次确保PasswordHash类被加载,并使用它来验证密码。 bcrypt是目前公认的比较安全的密码哈希算法,WordPress从2.5版本开始使用它。

PasswordHash 类:密码加密的幕后英雄

PasswordHash类是整个密码兼容机制的核心。它不仅负责验证旧的DES哈希,还负责验证新的bcrypt哈希。我们来看看它的关键方法:

  • PasswordHash( $iteration_count, $portable_hashes ): 构造函数。$iteration_count指定bcrypt算法的迭代次数,迭代次数越多,破解难度越大,但验证时间也会相应增加。$portable_hashes参数用于控制是否生成旧的DES哈希。

  • HashPassword( $password ): 根据当前配置生成密码哈希值。

  • CheckPassword( $password, $hash ): 验证密码是否与给定的哈希值匹配。

<?php
// class-phpass.php (简化版)

class PasswordHash {
    var $itoa64;
    var $iteration_count;
    var $portable_hashes;
    var $random_state;

    function PasswordHash( $iteration_count, $portable_hashes ) {
        $this->itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

        if ( $iteration_count < 4 )
            $iteration_count = 4;
        if ( $iteration_count > 31 )
            $iteration_count = 31;
        $this->iteration_count = $iteration_count;
        $this->portable_hashes = $portable_hashes;
        $this->random_state = microtime();
        if ( function_exists( 'getmypid' ) )
            $this->random_state .= getmypid();
    }

    function get_random_bytes( $count ) {
        $output = '';
        if ( @is_readable( '/dev/urandom' ) &&
            ( $fh = @fopen( '/dev/urandom', 'rb' ) ) ) {
            $output = fread( $fh, $count );
            fclose( $fh );
        }

        if ( strlen( $output ) < $count ) {
            $output = '';
            for ( $i = 0; $i < $count; $i += 16 ) {
                $this->random_state = md5( microtime() . $this->random_state );
                $output .= pack( 'H*', md5( $this->random_state ) );
            }
            $output = substr( $output, 0, $count );
        }
        return $output;
    }

    function encode64( $input, $count ) {
        $output = '';
        $i = 0;
        do {
            $value = ord( $input[ $i++ ] );
            $output .= $this->itoa64[ $value & 0x3f ];
            if ( $i < $count )
                $value |= ord( $input[ $i ] ) << 8;
            $output .= $this->itoa64[ ( $value >> 6 ) & 0x3f ];
            if ( $i++ >= $count )
                break;
            if ( $i < $count )
                $value |= ord( $input[ $i ] ) << 16;
            $output .= $this->itoa64[ ( $value >> 12 ) & 0x3f ];
            if ( $i++ >= $count )
                break;
            $output .= $this->itoa64[ ( $value >> 18 ) & 0x3f ];
        } while ( $i < $count );

        return $output;
    }

    function gensalt_private( $input ) {
        $output = '$P$';
        $output .= $this->itoa64[ min( $this->iteration_count + CRYPT_SALT_LENGTH, 54 ) ];
        $output .= $this->encode64( $input, CRYPT_SALT_LENGTH );

        return $output;
    }

    function crypt_private( $password, $setting ) {
        $output = '*0';
        if ( substr( $setting, 0, 2 ) == $output )
            $output = '*1';

        $id = substr( $setting, 0, 3 );
        # We use "$P$", phpBB's "$H$" means MD5...
        if ( $id != '$P$' )
            return $output;

        $count_log2 = strpos( $this->itoa64, $setting[3] );
        if ( $count_log2 < 7 || $count_log2 > 30 )
            return $output;

        $salt = substr( $setting, 4, 8 );
        if ( strlen( $salt ) != 8 )
            return $output;

        # We're kind of forced to use MD5 here since it's the only
        # cryptographic primitive available in all versions of PHP
        # currently in use.  To implement something lookalike PBKDF2
        # would require mcrypt, which is sadly not guaranteed.
        $count = 1 << $count_log2;
        do {
            $password = md5( $salt . $password, true );
        } while ( --$count );

        $output = substr( $setting, 0, 12 );
        $output .= $this->encode64( $password, 16 );

        return $output;
    }

    function HashPassword( $password ) {
        $random = $this->get_random_bytes(CRYPT_SALT_LENGTH);
        $hash = $this->crypt_private( $password, $this->gensalt_private( $random ) );
        if ( strlen( $hash ) == 34 )
            return $hash;

        # Returning '*' also means an error in crypt().
        # However, we rely on it in wp_check_password() to
        # recognize correct password hashes.
        return md5( $password );
    }

    function CheckPassword( $password, $hash ) {
        if ( substr( $hash, 0, 2 ) == '*0' )
            return false;
        if ( substr( $hash, 0, 2 ) == '*1' )
            return false;
        $crypt = $this->crypt_private( $password, $hash );
        if ( $crypt == $hash )
            return true;

        # If the hash is in the old format, update it.
        if ( substr( $hash, 0, 3 ) == '$P$' )
            return true;

        return false;
    }
}
?>

让我们深入了解一下PasswordHash类中的关键方法:

  1. crypt_private($password, $setting):DES哈希的核心

    这个方法是实现旧的DES哈希算法的关键。它接收明文密码和哈希设置(包含盐值和迭代次数),然后使用MD5进行多次哈希迭代。

    • 迭代次数: $count_log2 变量从哈希设置中提取迭代次数的对数。实际迭代次数是 1 << $count_log2。 这意味着迭代次数是2的幂,例如,如果 count_log2 是8,那么迭代次数就是256。
    • 盐值: $salt 是从哈希设置中提取的8个字符的盐值。盐值用于增加密码的安全性,防止彩虹表攻击。
    • MD5迭代: 密码和盐值会被拼接起来,然后使用MD5进行哈希。这个过程会重复多次,次数由迭代次数决定。
  2. HashPassword($password):生成新的哈希

    这个方法用于生成新的密码哈希。它首先生成一个随机盐值,然后调用crypt_private()方法来生成哈希。 需要注意的是,即使在PasswordHash类中,生成新哈希仍然使用MD5,这主要是为了兼容旧系统。更现代的WordPress版本使用更安全的bcrypt算法。

  3. CheckPassword($password, $hash):验证密码

    这个方法用于验证密码是否与给定的哈希匹配。它首先检查哈希是否是已知的不安全哈希(以*0*1开头),如果是,则直接返回false。然后,它调用crypt_private()方法来计算密码的哈希,并将其与给定的哈希进行比较。如果匹配,则返回true重点: 如果哈希是以 $P$ 开头的旧格式,即使密码匹配,该方法也会返回 true。 这是因为 wp_check_password() 函数会在验证成功后更新哈希到更安全的格式。

平滑升级的策略

现在,我们知道了wp_check_password()如何识别和验证不同版本的密码哈希。那么,它是如何实现平滑升级的呢?

答案就在于:验证成功后,立即更新哈希值。

wp_check_password()成功验证了一个旧的$P$哈希时,它会返回true。然后,WordPress会在后台使用新的bcrypt算法重新哈希密码,并更新数据库中的哈希值。这样,用户下次登录时,就会使用新的哈希值进行验证,而无需手动重置密码。

这种策略被称为“lazy migration”(懒惰迁移)或“just-in-time migration”(即时迁移)。它的优点是:

  • 对用户无感知: 用户无需任何操作即可完成密码升级。
  • 逐步迁移: 只有在用户登录时才会更新密码,避免了大规模的数据迁移,降低了服务器的压力。
  • 兼容性强: 可以兼容各种旧版本的密码哈希。

新时代的密码安全

虽然wp_check_password()在兼容性方面做得很好,但随着技术的发展,我们需要更安全的密码哈希算法。WordPress在后续版本中引入了wp_hash_password()wp_verify_password()这两个函数,它们使用了更强大的bcrypt算法,并提供了更多的安全选项。

  • wp_hash_password( $password ): 使用bcrypt算法生成密码哈希值。
  • wp_verify_password( $password, $hash ): 验证密码是否与给定的bcrypt哈希值匹配。

这些函数的使用,标志着WordPress密码安全进入了一个新的时代。

总结

wp_check_password()函数是WordPress密码兼容机制的核心。它通过识别不同版本的密码哈希,并采用“lazy migration”策略,实现了平滑升级,保证了用户体验。

以下表格总结了WordPress密码哈希的发展历程:

版本 哈希算法 哈希值前缀 安全性 是否支持 备注
WordPress 2.5之前 DES $P$ 支持 使用基于DES的哈希算法,安全性很低。 为了兼容性仍然支持,但验证后会立即更新为bcrypt。
WordPress 2.5之前 MD5 $H$ 非常低 不支持 使用MD5哈希算法,安全性极低。 WordPress不再支持,使用这种密码的用户必须重置密码。
WordPress 2.5之后 bcrypt 无固定前缀 支持 使用bcrypt算法,安全性较高。 是目前推荐的密码哈希算法。

通过对wp_check_password()函数的分析,我们可以看到WordPress在密码安全方面所做的努力。它不仅要保证网站的安全,还要兼顾用户体验,这是一个非常具有挑战性的任务。

希望今天的讲座能让大家对WordPress密码兼容机制有一个更深入的了解。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注