File: src/Compat.php

Recommend this page to a friend!
  Classes of Scott Arciszewski  >  PHP Password Strength Check  >  src/Compat.php  >  Download  
File: src/Compat.php
Role: Class source
Content type: text/plain
Description: Class source
Class: PHP Password Strength Check
Check the strength of a new password that changed
Author: By
Last change:
Date: 6 months ago
Size: 20,375 bytes
 

Contents

Class file image Download
<?php
declare(strict_types=1);
namespace ParagonIE\Passwdqc;
use ParagonIE\ConstantTime\Binary;

/**
 * Class Compat
 *
 * This class aims to be mostly API compatible with the original C
 * implementation of passwdqc.
 *
 * @package ParagonIE\Passwdqc
 */
abstract class Compat
{
    use WordList;

    const F_ENFORCE_MASK =         0x00000003;
    const F_ENFORCE_USERS =        0x00000001;
    const F_ENFORCE_ROOT =         0x00000002;
    const F_ENFORCE_EVERYONE =     self::F_ENFORCE_MASK;
    const F_NON_UNIX =             0x00000004;
    const F_ASK_OLDAUTHOK_MASK =   0x00000030;
    const F_ASK_OLDAUTHOK_PRELIM = 0x00000010;
    const F_ASK_OLDAUTHOK_UPDATE = 0x00000020;
    const F_CHECK_OLDAUTHOK =      0x00000040;
    const F_USE_FIRST_PASS =       0x00000100;
    const F_USE_AUTHTOK =          0x00000200;
    const PASSWDQC_VERSION =       '1.3.1';

    const REASON_ERROR = 'check failed';
    const REASON_SAME = 'is the same as the old one';
    const REASON_SIMILAR = 'is based on the old one';
    const REASON_SHORT = 'too short';
    const REASON_LONG = 'too long';
    const REASON_SIMPLESHORT = 'not enough different characters or classes for this length';
    const REASON_SIMPLE = 'not enough different characters or classes';
    const REASON_PERSONAL = 'based on personal login information';
    const REASON_WORD = 'based on a dictionary word and not a passphrase';
    const REASON_SEQ = 'based on a common sequence of characters and not a passphrase';

    const FIXED_BITS = 15;

    /**
     * @var string
     */
    protected static $lastReason;

    /**
     * @var string[]
     */
    protected static $seq = [
        "0123456789",
        "`1234567890-=",
        "~!@#$%^&*()_+",
        "abcdefghijklmnopqrstuvwxyz",
        "a1b2c3d4e5f6g7h8i9j0",
        "1a2b3c4d5e6f7g8h9i0j",
        "abc123",
        "qwertyuiop[]\\asdfghjkl;'zxcvbnm,./",
        "qwertyuiop{}|asdfghjkl:\"zxcvbnm<>?",
        "qwertyuiopasdfghjklzxcvbnm",
        "1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik,9ol.0p;/-['=]\\",
        "!qaz@wsx#edc\$rfv%tgb^yhn&ujm*ik<(ol>)p:?_{\"+}|",
        "qazwsxedcrfvtgbyhnujmikolp",
        "1q2w3e4r5t6y7u8i9o0p-[=]",
        "q1w2e3r4t5y6u7i8o9p0[-]=\\",
        "1qaz1qaz",
        "1qaz!qaz", /* can't unify '1' and '!' - see comment in unify() */
        "1qazzaq1",
        "zaq!1qaz",
        "zaq!2wsx"
    ];

    /**
     * Overloadable. Gets the default passwdqc parameters.
     *
     * @return Params
     */
    protected static function getDefaultParams(): Params
    {
        return new Params();
    }

    /**
     * Get the reason the last password was rejected.
     *
     * @return string
     */
    public static function getLastReason(): string
    {
        return self::$lastReason;
    }

    /**
     * Calculates the expected number of different characters for a random
     * password of a given length.  The result is rounded down.  We use this
     * with the _requested_ minimum length (so longer passwords don't have
     * to meet this strict requirement for their length).
     *
     * @param int $charset
     * @param int $length
     * @return int
     */
    public static function expected_different(int $charset, int $length): int
    {
        $x = ($charset - 1) << self::FIXED_BITS / $charset;
        $y = $x;
        while (--$length > 0) {
            $y = ($y * $x) >> self::FIXED_BITS;
        }
        $z = $charset * 1 << (self::FIXED_BITS - $y);
        return (int) ($z >> self::FIXED_BITS);
    }

    /**
     * Needle is based on haystack if both contain a long enough common
     * substring and needle would be too simple for a password with the
     * substring either removed with partial length credit for it added
     * or partially discounted for the purpose of the length check.
     *
     * @param Params|null $params
     * @param string $haystack
     * @param string $needle
     * @param string $original
     * @param int $mode
     * @return bool
     */
    public static function is_based(
        Params $params = null,
        string $haystack = '',
        string $needle = '',
        string $original = '',
        int $mode = 0
    ): bool {
        if ($params === null) {
            $params = static::getDefaultParams();
        }
        if ($params->getMatch() === 0) {
            return false;
        }
        if ($params->getMatch() < 0) {
            return true;
        }
        $length = Binary::safeStrlen($needle);
        $bias = 0;
        $worst_bias = 0;

        // Begin the loop:
        for ($i = 0; $i <= $length - $params->getMatch(); ++$i) {
            for ($j = $params->getMatch(); $i + $j <= $length; ++$j) {
                $bias = 0;
                $j1 = $j - 1;
                $q0 = $needle[$i];
                $q1 = Binary::safeSubstr($needle, $i + 1);

                /* Next 3 lines: for (p = haystack; *p; p++) */
                $haystack_len = Binary::safeStrlen($haystack);
                for ($k = 0; $k < $haystack_len; ++$k) {
                    $p = Binary::safeSubstr($haystack, $k);

                    /* if (*p == q0 && !strncmp(p + 1, q1, j1)) { */
                    if ($p[0] === $q0
                            &&
                        \strncmp(
                            Binary::safeSubstr($p, 1),
                            $q1,
                            $j1
                        ) === 0
                    ) {
                        if (($mode & 0xff) === 0) {
                            /* remove j chars */
                            $pos = $length - ($i + $j);
                            /* not reversed */
                            if (!($mode & 0x100)) {
                                $pos = $i;
                            }
                            $scratch = Binary::safeSubstr(
                                    $original,
                                    0,
                                    $pos
                                ) . Binary::safeSubstr(
                                    $original,
                                    ($pos + $j),
                                    ($length + 1 - ($pos + $j))
                                );
                            /* add credit for match_length - 1 chars */
                            $bias = $params->getMatch() - 1;
                            if (self::is_simple($params, $scratch, $bias, $bias)) {
                                return true;
                            }
                        } else {
                            /* discount */
                            /* Require a 1 character longer match for substrings containing leetspeak
                             * when matching against dictionary words */
                            $bias = -1;
                            /* words */
                            if (($mode & 0xff) === 1) {
                                $pos = $i;
                                $end = $i + $j;
                                /* reversed */
                                if (($mode & 0x100) > 0) {
                                    $pos = $length - $end;
                                    $end = $length - $i;
                                }
                                for (; $pos < $end; ++$pos) {
                                    if (!\ctype_alpha($original[$pos])) {
                                        if ($j === $params->getMatch()) {
                                            continue 3;
                                        }
                                        $bias = 0;
                                    }
                                }
                            }

                            /* discount j - (match_length + bias) chars */
                            $bias += $params->getMatch() - $j;
                            /* bias <= -1 */
                            if ($bias < $worst_bias) {
                                if (self::is_simple(
                                    $params,
                                    $original,
                                    $bias,
                                    (($mode & 0xff) === 1 ? 0 : $bias)
                                )) {
                                    return false;
                                }
                            }
                        }
                    }
                }
            }
            /* Zero bias implies that there were no matches for this length.  If so,
             * there's no reason to try the next substring length (it would result in
             * no matches as well).  We break out of the substring length loop and
             * proceed with all substring lengths for the next position in needle. */
            if ($bias === 0) {
                break;
            }
        }
        return false;
    }

    /**
     * A password is too simple if it is too short for its class, or doesn't
     * contain enough different characters for its class, or doesn't contain
     * enough words for a passphrase.
     *
     * The biases are added to the length, and they may be positive or negative.
     * The passphrase length check uses passphrase_bias instead of bias so that
     * zero may be passed for this parameter when the (other) bias is non-zero
     * because of a dictionary word, which is perfectly normal for a passphrase.
     * The biases do not affect the number of different characters, character
     * classes, and word count.
     *
     * @param Params|null $params
     * @param string $newPassword
     * @param int $bias
     * @param int $passphraseBias
     * @return bool
     */
    public static function is_simple(
        Params $params = null,
        string $newPassword = '',
        int $bias = 0,
        int $passphraseBias = 0
    ): bool {
        if ($params === null) {
            $params = static::getDefaultParams();
        }
        $length = Binary::safeStrlen($newPassword);
        if ($length < 1) {
            return true;
        }

        $words = $chars = 0;
        $p = '';

        $digits = $lowers = $uppers = $others = $unknowns = 0;

        for ($i = 0; $i < $length; ++$i) {
            $c = $newPassword[$i];
            if (!self::is_ascii($c)) {
                ++$unknowns;
            } elseif (\ctype_digit($c)) {
                ++$digits;
            } elseif (\ctype_lower($c)) {
                ++$lowers;
            } elseif (\ctype_upper($c)) {
                ++$uppers;
            } else {
                ++$others;
            }

            if (self::is_ascii($p)) {
                if (self::is_ascii($c)) {
                    if (\ctype_alpha($c) && !\ctype_alpha($p)) {
                        ++$words;
                    }
                } elseif (\ctype_space($p)) {
                    ++$words;
                }
            }
            $p = $c;

            if (!\strchr(Binary::safeSubstr($newPassword, $i), $c)) {
                ++$chars;
            }
        }
        $classes = 0;
        if (!empty($digits)) {
            ++$classes;
        }
        if (!empty($lowers)) {
            ++$classes;
        }
        if (!empty($uppers)) {
            ++$classes;
        }
        if (!empty($others)) {
            ++$classes;
        }
        if ($unknowns && $classes <= 1 && (!$classes || $digits || $words >= 2)) {
            ++$classes;
        }
        for (; $classes > 0; --$classes) {
            switch ($classes) {
                case 4:
                    if (
                        $length + $bias >= $params->getMin(4)
                            &&
                        $chars >= self::expected_different(95, $params->getMin(4) - 1)
                    ) {
                        return true;
                    }
                    break;

                case 3:
                    if (
                        $length + $bias >= $params->getMin(4)
                        &&
                        $chars >= self::expected_different(62, $params->getMin(4) - 1)
                    ) {
                        return true;
                    }
                    break;

                case 2:
                    if (
                        $length + $bias >= $params->getMin(1)
                            &&
                        $chars >= self::expected_different(36, $params->getMin(1) - 1)
                    ) {
                        return true;
                    }
                    if (!$params->getPassphrase() || $words < $params->getPassphrase()) {
                        break;
                    }
                    if ($length + $passphraseBias >= $params->getMin(2)
                        &&
                        $chars >= self::expected_different(27, $params->getMin(2) - 1)
                    ) {
                        return true;
                    }

                    break;

                case 1:
                    return (
                        $length + $bias >= $params->getMin(0)
                            &&
                        $chars >= self::expected_different(10, $params->getMin(0) - 1)
                    );
            }
        }
        return false;
    }

    /**
     * @param Params|null $params
     * @param string $needle
     * @param string $original
     * @param bool $isReversed
     * @return bool
     */
    public static function is_word_based(
        Params $params = null,
        string $needle = '',
        string $original = '',
        bool $isReversed = false
    ): bool {
        if (empty($params)) {
            $params = static::getDefaultParams();
        }
        if ($params->getMatch() === 0) {
            return false;
        }
        $mode = $isReversed ? 0x0101 : 0x0001;

        // Reject based on wordlist:
        foreach (self::$wordList as $word) {
            if (self::is_based($params, $word, $needle, $original, $mode)) {
                return !self::reject(self::REASON_WORD);
            }
        }

        // Reject sequences:
        $mode = $isReversed ? 0x0102 : 0x0002;
        for ($i = 0; $i < \count(self::$seq); ++$i) {
            $unified = self::unify(self::$seq[$i]);
            if (self::is_based($params, $unified, $needle, $original, $mode)) {
                return !self::reject(self::REASON_SEQ);
            }
        }

        // Reject birth years:
        if ($params->getMatch() <= 4) {
            for ($i = 1900; $i <= 2039; ++$i) {
                if (self::is_based($params, (string) $i, $needle, $original, $mode)) {
                    return !self::reject(self::REASON_SEQ);
                }
            }
        }
        return false;
    }

    /**
     * Perform a passwdqc check. This is more-or-less compatible with the C
     * API.
     *
     * @param Params|null $params
     * @param string $newPassword
     * @param string $oldPassword
     * @param UserInformation|null $pw
     * @return bool
     */
    public static function passwdqc_check(
        Params $params = null,
        string $newPassword = '',
        string $oldPassword = '',
        UserInformation $pw = null
    ): bool {
        if ($params === null) {
            $params = static::getDefaultParams();
        }
        $length = Binary::safeStrlen($newPassword);

        if ($length < $params->getMin(4)) {
            return self::reject(self::REASON_SHORT);
        }

        if ($length > 10000) {
            return self::reject(self::REASON_LONG);
        }

        if ($length > $params->getMax()) {
            if ($params->getMax() === 8) {
                // Compatibility with old DES-based crypt():
                $newPassword = Binary::safeSubstr($newPassword, 0, 8);
                $length = 8;
                if ($oldPassword && \strncmp($newPassword, $oldPassword, 8) !== 0) {
                    return self::reject(self::REASON_SAME);
                }
            } else {
                return self::reject(self::REASON_LONG);
            }
        }

        if ($oldPassword && \hash_equals($newPassword, $oldPassword)) {
            return self::reject(self::REASON_SAME);
        }

        if (static::is_simple($params, $newPassword, 0, 0)) {
            if (
                $length < $params->getMin(1)
                    &&
                $params->getMin(1) <= $params->getMax()
            ) {
                return self::reject(self::REASON_SIMPLESHORT);
            }
            return self::reject(self::REASON_SIMPLE);
        }

        $u_newpass = self::unify($newPassword);
        $u_reversed = \strrev($u_newpass);
        if ($oldPassword && $params->getSimilarDeny()) {
            $u_oldpass = self::unify($oldPassword);

            if (
                self::is_based($params, $u_oldpass, $u_newpass, $newPassword, 0)
                    ||
                self::is_based($params, $u_oldpass, $u_reversed, $newPassword, 0x100)
            ) {
                return self::reject(self::REASON_SIMILAR);
            }
        }

        if ($pw instanceof UserInformation) {
            $u_name = self::unify($pw->getName());
            $u_gecos = self::unify($pw->getGecos());
            $u_dir = self::unify($pw->getDir());

            if (
                self::is_based($params, $u_name, $u_newpass, $newPassword, 0)
                    ||
                self::is_based($params, $u_name, $u_reversed, $newPassword, 0x100)
                    ||
                self::is_based($params, $u_gecos, $u_newpass, $newPassword, 0)
                    ||
                self::is_based($params, $u_gecos, $u_reversed, $newPassword, 0x100)
                    ||
                self::is_based($params, $u_dir, $u_newpass, $newPassword, 0)
                    ||
                self::is_based($params, $u_dir, $u_reversed, $newPassword, 0x100)
            ) {
                return self::reject(self::REASON_PERSONAL);
            }
        }

        $isWordBased = self::is_word_based(
            $params,
            $u_newpass,
            $newPassword
        );
        if (!$isWordBased) {
            // Let's also check the reverse:
            $isWordBased = self::is_word_based(
                $params,
                $u_reversed,
                $newPassword,
                true
            );
        }
        return !$isWordBased;
    }

    /**
     * Set the last rejection reason, then return false.
     *
     * @param string $reason
     * @return bool
     */
    protected static function reject(string $reason): bool
    {
        self::$lastReason = $reason;
        return false;
    }

    /**
     * @param string $source
     * @return string
     */
    public static function unify(string $source): string
    {
        $dst = '';
        $length = Binary::safeStrlen($source);
        for ($i = 0; $i < $length; ++$i) {
            $c = \strtolower($source[$i]);
            switch ($c) {
                case 'a':
                case '@':
                    $dst .= '4';
                    break;
                case 'e':
                    $dst .= '3';
                    break;
                /* Unfortunately, if we translate both 'i' and 'l' to '1', this would
                 * associate these two letters with each other - e.g., "mile" would
                 * match "MLLE", which is undesired.  To solve this, we'd need to test
                 * different translations separately, which is not implemented yet. */
                case 'i':
                case '|':
                    $dst .= '!';
                    break;
                case 'l':
                    $dst .= '1';
                    break;
                case 'o':
                    $dst .= '0';
                    break;
                case 's':
                case '$':
                    $dst .= '5';
                    break;
                case 't':
                case '+':
                    $dst .= '7';
                    break;
                default:
                    $dst .= $c;
            }
        }
        return $dst;
    }


    /**
     * @param string $c
     * @return bool
     */
    public static function is_ascii(string $c): bool
    {
        if (Binary::safeStrlen($c) < 1) {
            return false;
        }
        $chunk = \unpack('C', $c);
        return $chunk[1] >= 0 && $chunk[1] <= 127;
    }
}

For more information send a message to info at phpclasses dot org.