PHP Classes

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: 3 years 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; } }