implement JS password scoring, td_stock approve logicc
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp;
|
||||
|
||||
use ZxcvbnPhp\Matchers\MatchInterface;
|
||||
|
||||
/**
|
||||
* Feedback - gives some user guidance based on the strength
|
||||
* of a password
|
||||
*
|
||||
* @see zxcvbn/src/feedback.coffee
|
||||
*/
|
||||
class Feedback
|
||||
{
|
||||
/**
|
||||
* @param int $score
|
||||
* @param MatchInterface[] $sequence
|
||||
* @return array
|
||||
*/
|
||||
public function getFeedback(int $score, array $sequence): array
|
||||
{
|
||||
// starting feedback
|
||||
if (count($sequence) === 0) {
|
||||
return [
|
||||
'warning' => '',
|
||||
'suggestions' => [
|
||||
"Use a few words, avoid common phrases",
|
||||
"No need for symbols, digits, or uppercase letters",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// no feedback if score is good or great.
|
||||
if ($score > 2) {
|
||||
return [
|
||||
'warning' => '',
|
||||
'suggestions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// tie feedback to the longest match for longer sequences
|
||||
$longestMatch = $sequence[0];
|
||||
foreach (array_slice($sequence, 1) as $match) {
|
||||
if (mb_strlen($match->token) > mb_strlen($longestMatch->token)) {
|
||||
$longestMatch = $match;
|
||||
}
|
||||
}
|
||||
|
||||
$feedback = $longestMatch->getFeedback(count($sequence) === 1);
|
||||
$extraFeedback = 'Add another word or two. Uncommon words are better.';
|
||||
|
||||
array_unshift($feedback['suggestions'], $extraFeedback);
|
||||
return $feedback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp;
|
||||
|
||||
use ZxcvbnPhp\Matchers\BaseMatch;
|
||||
use ZxcvbnPhp\Matchers\MatchInterface;
|
||||
|
||||
class Matcher
|
||||
{
|
||||
private const DEFAULT_MATCHERS = [
|
||||
Matchers\DateMatch::class,
|
||||
Matchers\DictionaryMatch::class,
|
||||
Matchers\ReverseDictionaryMatch::class,
|
||||
Matchers\L33tMatch::class,
|
||||
Matchers\RepeatMatch::class,
|
||||
Matchers\SequenceMatch::class,
|
||||
Matchers\SpatialMatch::class,
|
||||
Matchers\YearMatch::class,
|
||||
];
|
||||
|
||||
private $additionalMatchers = [];
|
||||
|
||||
/**
|
||||
* Get matches for a password.
|
||||
*
|
||||
* @param string $password Password string to match
|
||||
* @param array $userInputs Array of values related to the user (optional)
|
||||
* @code array('Alice Smith')
|
||||
* @endcode
|
||||
*
|
||||
* @return MatchInterface[] Array of Match objects.
|
||||
*
|
||||
* @see zxcvbn/src/matching.coffee::omnimatch
|
||||
*/
|
||||
public function getMatches(string $password, array $userInputs = []): array
|
||||
{
|
||||
$matches = [];
|
||||
foreach ($this->getMatchers() as $matcher) {
|
||||
$matched = $matcher::match($password, $userInputs);
|
||||
if (is_array($matched) && !empty($matched)) {
|
||||
$matches[] = $matched;
|
||||
}
|
||||
}
|
||||
|
||||
$matches = array_merge([], ...$matches);
|
||||
self::usortStable($matches, [$this, 'compareMatches']);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
public function addMatcher(string $className): self
|
||||
{
|
||||
if (!is_a($className, MatchInterface::class, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Matcher class must implement %s', MatchInterface::class));
|
||||
}
|
||||
|
||||
$this->additionalMatchers[$className] = $className;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable implementation of usort().
|
||||
*
|
||||
* Whether or not the sort() function in JavaScript is stable or not is implementation-defined.
|
||||
* This means it's impossible for us to match all browsers exactly, but since most browsers implement sort() using
|
||||
* a stable sorting algorithm, we'll get the highest rate of accuracy by using a stable sort in our code as well.
|
||||
*
|
||||
* This function taken from https://github.com/vanderlee/PHP-stable-sort-functions
|
||||
* Copyright © 2015-2018 Martijn van der Lee (http://martijn.vanderlee.com). MIT License applies.
|
||||
*
|
||||
* @param array $array
|
||||
* @param callable $value_compare_func
|
||||
* @return bool
|
||||
*/
|
||||
public static function usortStable(array &$array, callable $value_compare_func): bool
|
||||
{
|
||||
$index = 0;
|
||||
foreach ($array as &$item) {
|
||||
$item = [$index++, $item];
|
||||
}
|
||||
$result = usort($array, function ($a, $b) use ($value_compare_func) {
|
||||
$result = $value_compare_func($a[1], $b[1]);
|
||||
return $result == 0 ? $a[0] - $b[0] : $result;
|
||||
});
|
||||
foreach ($array as &$item) {
|
||||
$item = $item[1];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function compareMatches(BaseMatch $a, BaseMatch $b): int
|
||||
{
|
||||
$beginDiff = $a->begin - $b->begin;
|
||||
if ($beginDiff) {
|
||||
return $beginDiff;
|
||||
}
|
||||
return $a->end - $b->end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available Match objects to match against a password.
|
||||
*
|
||||
* @return array Array of classes implementing MatchInterface
|
||||
*/
|
||||
protected function getMatchers(): array
|
||||
{
|
||||
return array_merge(
|
||||
self::DEFAULT_MATCHERS,
|
||||
array_values($this->additionalMatchers)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Math\Binomial;
|
||||
use ZxcvbnPhp\Scorer;
|
||||
|
||||
abstract class BaseMatch implements MatchInterface
|
||||
{
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $password;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $begin;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $end;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $token;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $pattern;
|
||||
|
||||
public function __construct(string $password, int $begin, int $end, string $token)
|
||||
{
|
||||
$this->password = $password;
|
||||
$this->begin = $begin;
|
||||
$this->end = $end;
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback to a user based on the match.
|
||||
*
|
||||
* @param bool $isSoleMatch
|
||||
* Whether this is the only match in the password
|
||||
* @return array
|
||||
* Associative array with warning (string) and suggestions (array of strings)
|
||||
*/
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
abstract public function getFeedback(bool $isSoleMatch): array;
|
||||
|
||||
/**
|
||||
* Find all occurrences of regular expression in a string.
|
||||
*
|
||||
* @param string $string
|
||||
* String to search.
|
||||
* @param string $regex
|
||||
* Regular expression with captures.
|
||||
* @param int $offset
|
||||
* @return array
|
||||
* Array of capture groups. Captures in a group have named indexes: 'begin', 'end', 'token'.
|
||||
* e.g. fishfish /(fish)/
|
||||
* array(
|
||||
* array(
|
||||
* array('begin' => 0, 'end' => 3, 'token' => 'fish'),
|
||||
* array('begin' => 0, 'end' => 3, 'token' => 'fish')
|
||||
* ),
|
||||
* array(
|
||||
* array('begin' => 4, 'end' => 7, 'token' => 'fish'),
|
||||
* array('begin' => 4, 'end' => 7, 'token' => 'fish')
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public static function findAll(string $string, string $regex, int $offset = 0): array
|
||||
{
|
||||
// $offset is the number of multibyte-aware number of characters to offset, but the offset parameter for
|
||||
// preg_match_all counts bytes, not characters: to correct this, we need to calculate the byte offset and pass
|
||||
// that in instead.
|
||||
$charsBeforeOffset = mb_substr($string, 0, $offset);
|
||||
$byteOffset = strlen($charsBeforeOffset);
|
||||
|
||||
$count = preg_match_all($regex, $string, $matches, PREG_SET_ORDER, $byteOffset);
|
||||
if (!$count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($matches as $group) {
|
||||
$captureBegin = 0;
|
||||
$match = array_shift($group);
|
||||
$matchBegin = mb_strpos($string, $match, $offset);
|
||||
$captures = [
|
||||
[
|
||||
'begin' => $matchBegin,
|
||||
'end' => $matchBegin + mb_strlen($match) - 1,
|
||||
'token' => $match,
|
||||
],
|
||||
];
|
||||
foreach ($group as $capture) {
|
||||
$captureBegin = mb_strpos($match, $capture, $captureBegin);
|
||||
$captures[] = [
|
||||
'begin' => $matchBegin + $captureBegin,
|
||||
'end' => $matchBegin + $captureBegin + mb_strlen($capture) - 1,
|
||||
'token' => $capture,
|
||||
];
|
||||
}
|
||||
$groups[] = $captures;
|
||||
$offset += mb_strlen($match) - 1;
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate binomial coefficient (n choose k).
|
||||
*
|
||||
* @param int $n
|
||||
* @param int $k
|
||||
* @return float
|
||||
* @deprecated Use {@see Binomial::binom()} instead
|
||||
*/
|
||||
public static function binom(int $n, int $k): float
|
||||
{
|
||||
return Binomial::binom($n, $k);
|
||||
}
|
||||
|
||||
abstract protected function getRawGuesses(): float;
|
||||
|
||||
public function getGuesses(): float
|
||||
{
|
||||
return max($this->getRawGuesses(), $this->getMinimumGuesses());
|
||||
}
|
||||
|
||||
protected function getMinimumGuesses(): float
|
||||
{
|
||||
if (mb_strlen($this->token) < mb_strlen($this->password)) {
|
||||
if (mb_strlen($this->token) === 1) {
|
||||
return Scorer::MIN_SUBMATCH_GUESSES_SINGLE_CHAR;
|
||||
} else {
|
||||
return Scorer::MIN_SUBMATCH_GUESSES_MULTI_CHAR;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getGuessesLog10(): float
|
||||
{
|
||||
return log10($this->getGuesses());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Scorer;
|
||||
|
||||
/**
|
||||
* Class Bruteforce
|
||||
* @package ZxcvbnPhp\Matchers
|
||||
*
|
||||
* Intentionally not named with Match suffix to prevent autoloading from Matcher.
|
||||
*/
|
||||
class Bruteforce extends BaseMatch
|
||||
{
|
||||
public const BRUTEFORCE_CARDINALITY = 10;
|
||||
|
||||
public $pattern = 'bruteforce';
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @return Bruteforce[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = []): array
|
||||
{
|
||||
// Matches entire string.
|
||||
$match = new static($password, 0, mb_strlen($password) - 1, $password);
|
||||
return [$match];
|
||||
}
|
||||
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
return [
|
||||
'warning' => "",
|
||||
'suggestions' => [
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getRawGuesses(): float
|
||||
{
|
||||
$guesses = pow(self::BRUTEFORCE_CARDINALITY, mb_strlen($this->token));
|
||||
if ($guesses === INF) {
|
||||
return PHP_FLOAT_MAX;
|
||||
}
|
||||
|
||||
// small detail: make bruteforce matches at minimum one guess bigger than smallest allowed
|
||||
// submatch guesses, such that non-bruteforce submatches over the same [i..j] take precedence.
|
||||
if (mb_strlen($this->token) === 1) {
|
||||
$minGuesses = Scorer::MIN_SUBMATCH_GUESSES_SINGLE_CHAR + 1;
|
||||
} else {
|
||||
$minGuesses = Scorer::MIN_SUBMATCH_GUESSES_MULTI_CHAR + 1;
|
||||
}
|
||||
|
||||
return max($guesses, $minGuesses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
|
||||
class DateMatch extends BaseMatch
|
||||
{
|
||||
public const NUM_YEARS = 119; // Years match against 1900 - 2019
|
||||
public const NUM_MONTHS = 12;
|
||||
public const NUM_DAYS = 31;
|
||||
|
||||
public const MIN_YEAR = 1000;
|
||||
public const MAX_YEAR = 2050;
|
||||
|
||||
public const MIN_YEAR_SPACE = 20;
|
||||
|
||||
public $pattern = 'date';
|
||||
|
||||
private static $DATE_SPLITS = [
|
||||
4 => [ # For length-4 strings, eg 1191 or 9111, two ways to split:
|
||||
[1, 2], # 1 1 91 (2nd split starts at index 1, 3rd at index 2)
|
||||
[2, 3], # 91 1 1
|
||||
],
|
||||
5 => [
|
||||
[1, 3], # 1 11 91
|
||||
[2, 3] # 11 1 91
|
||||
],
|
||||
6 => [
|
||||
[1, 2], # 1 1 1991
|
||||
[2, 4], # 11 11 91
|
||||
[4, 5], # 1991 1 1
|
||||
],
|
||||
7 => [
|
||||
[1, 3], # 1 11 1991
|
||||
[2, 3], # 11 1 1991
|
||||
[4, 5], # 1991 1 11
|
||||
[4, 6], # 1991 11 1
|
||||
],
|
||||
8 => [
|
||||
[2, 4], # 11 11 1991
|
||||
[4, 6], # 1991 11 11
|
||||
],
|
||||
];
|
||||
|
||||
protected const DATE_NO_SEPARATOR = '/^\d{4,8}$/u';
|
||||
|
||||
/**
|
||||
* (\d{1,4}) # day, month, year
|
||||
* ([\s\/\\\\_.-]) # separator
|
||||
* (\d{1,2}) # day, month
|
||||
* \2 # same separator
|
||||
* (\d{1,4}) # day, month, year
|
||||
*/
|
||||
protected const DATE_WITH_SEPARATOR = '/^(\d{1,4})([\s\/\\\\_.-])(\d{1,2})\2(\d{1,4})$/u';
|
||||
|
||||
/** @var int The day portion of the date in the token. */
|
||||
public $day;
|
||||
|
||||
/** @var int The month portion of the date in the token. */
|
||||
public $month;
|
||||
|
||||
/** @var int The year portion of the date in the token. */
|
||||
public $year;
|
||||
|
||||
/** @var string The separator used for the date in the token. */
|
||||
public $separator;
|
||||
|
||||
/**
|
||||
* Match occurences of dates in a password
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @return DateMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = []): array
|
||||
{
|
||||
# a "date" is recognized as:
|
||||
# any 3-tuple that starts or ends with a 2- or 4-digit year,
|
||||
# with 2 or 0 separator chars (1.1.91 or 1191),
|
||||
# maybe zero-padded (01-01-91 vs 1-1-91),
|
||||
# a month between 1 and 12,
|
||||
# a day between 1 and 31.
|
||||
#
|
||||
# note: this isn't true date parsing in that "feb 31st" is allowed,
|
||||
# this doesn't check for leap years, etc.
|
||||
#
|
||||
# recipe:
|
||||
# start with regex to find maybe-dates, then attempt to map the integers
|
||||
# onto month-day-year to filter the maybe-dates into dates.
|
||||
# finally, remove matches that are substrings of other matches to reduce noise.
|
||||
#
|
||||
# note: instead of using a lazy or greedy regex to find many dates over the full string,
|
||||
# this uses a ^...$ regex against every substring of the password -- less performant but leads
|
||||
# to every possible date match.
|
||||
$matches = [];
|
||||
$dates = static::removeRedundantMatches(array_merge(
|
||||
static::datesWithoutSeparators($password),
|
||||
static::datesWithSeparators($password)
|
||||
));
|
||||
foreach ($dates as $date) {
|
||||
$matches[] = new static($password, $date['begin'], $date['end'], $date['token'], $date);
|
||||
}
|
||||
Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
|
||||
return $matches;
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
return [
|
||||
'warning' => "Dates are often easy to guess",
|
||||
'suggestions' => [
|
||||
'Avoid dates and years that are associated with you'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @param string $token
|
||||
* @param array $params An array with keys: [day, month, year, separator].
|
||||
*/
|
||||
public function __construct(string $password, int $begin, int $end, string $token, array $params)
|
||||
{
|
||||
parent::__construct($password, $begin, $end, $token);
|
||||
$this->day = $params['day'];
|
||||
$this->month = $params['month'];
|
||||
$this->year = $params['year'];
|
||||
$this->separator = $params['separator'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find dates with separators in a password.
|
||||
*
|
||||
* @param string $password
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function datesWithSeparators(string $password): array
|
||||
{
|
||||
$matches = [];
|
||||
$length = mb_strlen($password);
|
||||
|
||||
// dates with separators are between length 6 '1/1/91' and 10 '11/11/1991'
|
||||
for ($begin = 0; $begin < $length - 5; $begin++) {
|
||||
for ($end = $begin + 5; $end - $begin < 10 && $end < $length; $end++) {
|
||||
$token = mb_substr($password, $begin, $end - $begin + 1);
|
||||
|
||||
if (!preg_match(static::DATE_WITH_SEPARATOR, $token, $captures)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = static::checkDate([
|
||||
(int) $captures[1],
|
||||
(int) $captures[3],
|
||||
(int) $captures[4]
|
||||
]);
|
||||
|
||||
if ($date === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'begin' => $begin,
|
||||
'end' => $end,
|
||||
'token' => $token,
|
||||
'separator' => $captures[2],
|
||||
'day' => $date['day'],
|
||||
'month' => $date['month'],
|
||||
'year' => $date['year'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find dates without separators in a password.
|
||||
*
|
||||
* @param string $password
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function datesWithoutSeparators(string $password): array
|
||||
{
|
||||
$matches = [];
|
||||
$length = mb_strlen($password);
|
||||
|
||||
// dates without separators are between length 4 '1191' and 8 '11111991'
|
||||
for ($begin = 0; $begin < $length - 3; $begin++) {
|
||||
for ($end = $begin + 3; $end - $begin < 8 && $end < $length; $end++) {
|
||||
$token = mb_substr($password, $begin, $end - $begin + 1);
|
||||
|
||||
if (!preg_match(static::DATE_NO_SEPARATOR, $token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
|
||||
$possibleSplits = static::$DATE_SPLITS[mb_strlen($token)];
|
||||
foreach ($possibleSplits as $splitPositions) {
|
||||
$day = (int)mb_substr($token, 0, $splitPositions[0]);
|
||||
$month = (int)mb_substr($token, $splitPositions[0], $splitPositions[1] - $splitPositions[0]);
|
||||
$year = (int)mb_substr($token, $splitPositions[1]);
|
||||
|
||||
$date = static::checkDate([$day, $month, $year]);
|
||||
if ($date !== false) {
|
||||
$candidates[] = $date;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// at this point: different possible dmy mappings for the same i,j substring.
|
||||
// match the candidate date that likely takes the fewest guesses: a year closest to
|
||||
// the current year.
|
||||
//
|
||||
// ie, considering '111504', prefer 11-15-04 to 1-1-1504
|
||||
// (interpreting '04' as 2004)
|
||||
$bestCandidate = $candidates[0];
|
||||
$minDistance = self::getDistanceForMatchCandidate($bestCandidate);
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$distance = self::getDistanceForMatchCandidate($candidate);
|
||||
if ($distance < $minDistance) {
|
||||
$bestCandidate = $candidate;
|
||||
$minDistance = $distance;
|
||||
}
|
||||
}
|
||||
|
||||
$day = $bestCandidate['day'];
|
||||
$month = $bestCandidate['month'];
|
||||
$year = $bestCandidate['year'];
|
||||
|
||||
$matches[] = [
|
||||
'begin' => $begin,
|
||||
'end' => $end,
|
||||
'token' => $token,
|
||||
'separator' => '',
|
||||
'day' => $day,
|
||||
'month' => $month,
|
||||
'year' => $year
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $candidate
|
||||
* @return int Returns the number of years between the detected year and the current year for a candidate.
|
||||
*/
|
||||
protected static function getDistanceForMatchCandidate(array $candidate): int
|
||||
{
|
||||
return abs((int)$candidate['year'] - static::getReferenceYear());
|
||||
}
|
||||
|
||||
public static function getReferenceYear(): int
|
||||
{
|
||||
return (int)date('Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ints Three numbers in an array representing day, month and year (not necessarily in that order).
|
||||
* @return array|bool Returns an associative array containing 'day', 'month' and 'year' keys, or false if the
|
||||
* provided date array is invalid.
|
||||
*/
|
||||
protected static function checkDate(array $ints)
|
||||
{
|
||||
# given a 3-tuple, discard if:
|
||||
# middle int is over 31 (for all dmy formats, years are never allowed in the middle)
|
||||
# middle int is zero
|
||||
# any int is over the max allowable year
|
||||
# any int is over two digits but under the min allowable year
|
||||
# 2 ints are over 31, the max allowable day
|
||||
# 2 ints are zero
|
||||
# all ints are over 12, the max allowable month
|
||||
if ($ints[1] > 31 || $ints[1] <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$invalidYear = count(array_filter($ints, function (int $int): bool {
|
||||
return ($int >= 100 && $int < static::MIN_YEAR)
|
||||
|| ($int > static::MAX_YEAR);
|
||||
}));
|
||||
if ($invalidYear > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$over12 = count(array_filter($ints, function (int $int): bool {
|
||||
return $int > 12;
|
||||
}));
|
||||
$over31 = count(array_filter($ints, function (int $int): bool {
|
||||
return $int > 31;
|
||||
}));
|
||||
$under1 = count(array_filter($ints, function (int $int): bool {
|
||||
return $int <= 0;
|
||||
}));
|
||||
|
||||
if ($over31 >= 2 || $over12 == 3 || $under1 >= 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
# first look for a four digit year: yyyy + daymonth or daymonth + yyyy
|
||||
$possibleYearSplits = [
|
||||
[$ints[2], [$ints[0], $ints[1]]], // year last
|
||||
[$ints[0], [$ints[1], $ints[2]]], // year first
|
||||
];
|
||||
|
||||
foreach ($possibleYearSplits as [$year, $rest]) {
|
||||
if ($year >= static::MIN_YEAR && $year <= static::MAX_YEAR) {
|
||||
if ($dm = static::mapIntsToDayMonth($rest)) {
|
||||
return [
|
||||
'year' => $year,
|
||||
'month' => $dm['month'],
|
||||
'day' => $dm['day'],
|
||||
];
|
||||
}
|
||||
# for a candidate that includes a four-digit year,
|
||||
# when the remaining ints don't match to a day and month,
|
||||
# it is not a date.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($possibleYearSplits as [$year, $rest]) {
|
||||
if ($dm = static::mapIntsToDayMonth($rest)) {
|
||||
return [
|
||||
'year' => static::twoToFourDigitYear($year),
|
||||
'month' => $dm['month'],
|
||||
'day' => $dm['day'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ints Two numbers in an array representing day and month (not necessarily in that order).
|
||||
* @return array|bool Returns an associative array containing 'day' and 'month' keys, or false if any combination
|
||||
* of the two numbers does not match a day and month.
|
||||
*/
|
||||
protected static function mapIntsToDayMonth(array $ints)
|
||||
{
|
||||
foreach ([$ints, array_reverse($ints)] as [$d, $m]) {
|
||||
if ($d >= 1 && $d <= 31 && $m >= 1 && $m <= 12) {
|
||||
return [
|
||||
'day' => $d,
|
||||
'month' => $m
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $year A two digit number representing a year.
|
||||
* @return int Returns the most likely four digit year for the provided number.
|
||||
*/
|
||||
protected static function twoToFourDigitYear(int $year): int
|
||||
{
|
||||
if ($year > 99) {
|
||||
return $year;
|
||||
}
|
||||
|
||||
if ($year > 50) {
|
||||
// 87 -> 1987
|
||||
return $year + 1900;
|
||||
}
|
||||
|
||||
// 15 -> 2015
|
||||
return $year + 2000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes date matches that are strict substrings of others.
|
||||
*
|
||||
* This is helpful because the match function will contain matches for all valid date strings in a way that is
|
||||
* tricky to capture with regexes only. While thorough, it will contain some unintuitive noise:
|
||||
*
|
||||
* '2015_06_04', in addition to matching 2015_06_04, will also contain
|
||||
* 5(!) other date matches: 15_06_04, 5_06_04, ..., even 2015 (matched as 5/1/2020)
|
||||
*
|
||||
* @param array $matches An array of matches (not Match objects)
|
||||
* @return array The provided array of matches, but with matches that are strict substrings of others removed.
|
||||
*/
|
||||
protected static function removeRedundantMatches(array $matches): array
|
||||
{
|
||||
return array_filter($matches, function (array $match) use ($matches): bool {
|
||||
foreach ($matches as $otherMatch) {
|
||||
if ($match === $otherMatch) {
|
||||
continue;
|
||||
}
|
||||
if ($otherMatch['begin'] <= $match['begin'] && $otherMatch['end'] >= $match['end']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
// base guesses: (year distance from REFERENCE_YEAR) * num_days * num_years
|
||||
$yearSpace = max(abs($this->year - static::getReferenceYear()), static::MIN_YEAR_SPACE);
|
||||
$guesses = $yearSpace * 365;
|
||||
|
||||
// add factor of 4 for separator selection (one of ~4 choices)
|
||||
if ($this->separator) {
|
||||
$guesses *= 4;
|
||||
}
|
||||
|
||||
return $guesses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
use ZxcvbnPhp\Math\Binomial;
|
||||
|
||||
class DictionaryMatch extends BaseMatch
|
||||
{
|
||||
public $pattern = 'dictionary';
|
||||
|
||||
/** @var string The name of the dictionary that the token was found in. */
|
||||
public $dictionaryName;
|
||||
|
||||
/** @var int The rank of the token in the dictionary. */
|
||||
public $rank;
|
||||
|
||||
/** @var string The word that was matched from the dictionary. */
|
||||
public $matchedWord;
|
||||
|
||||
/** @var bool Whether or not the matched word was reversed in the token. */
|
||||
public $reversed = false;
|
||||
|
||||
/** @var bool Whether or not the token contained l33t substitutions. */
|
||||
public $l33t = false;
|
||||
|
||||
/** @var array A cache of the frequency_lists json file */
|
||||
protected static $rankedDictionaries = [];
|
||||
|
||||
protected const START_UPPER = "/^[A-Z][^A-Z]+$/u";
|
||||
protected const END_UPPER = "/^[^A-Z]+[A-Z]$/u";
|
||||
protected const ALL_UPPER = "/^[^a-z]+$/u";
|
||||
protected const ALL_LOWER = "/^[^A-Z]+$/u";
|
||||
|
||||
/**
|
||||
* Match occurrences of dictionary words in password.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @param array $rankedDictionaries
|
||||
* @return DictionaryMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = [], array $rankedDictionaries = []): array
|
||||
{
|
||||
$matches = [];
|
||||
if ($rankedDictionaries) {
|
||||
$dicts = $rankedDictionaries;
|
||||
} else {
|
||||
$dicts = static::getRankedDictionaries();
|
||||
}
|
||||
|
||||
if (!empty($userInputs)) {
|
||||
$dicts['user_inputs'] = [];
|
||||
foreach ($userInputs as $rank => $input) {
|
||||
$input_lower = mb_strtolower($input);
|
||||
$dicts['user_inputs'][$input_lower] = $rank + 1; // rank starts at 1, not 0
|
||||
}
|
||||
}
|
||||
foreach ($dicts as $name => $dict) {
|
||||
$results = static::dictionaryMatch($password, $dict);
|
||||
foreach ($results as $result) {
|
||||
$result['dictionary_name'] = $name;
|
||||
$matches[] = new static($password, $result['begin'], $result['end'], $result['token'], $result);
|
||||
}
|
||||
}
|
||||
Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @param string $token
|
||||
* @param array $params An array with keys: [dictionary_name, matched_word, rank].
|
||||
*/
|
||||
public function __construct(string $password, int $begin, int $end, string $token, array $params = [])
|
||||
{
|
||||
parent::__construct($password, $begin, $end, $token);
|
||||
if (!empty($params)) {
|
||||
$this->dictionaryName = $params['dictionary_name'] ?? '';
|
||||
$this->matchedWord = $params['matched_word'] ?? '';
|
||||
$this->rank = $params['rank'] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isSoleMatch
|
||||
* @return array
|
||||
*/
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
$startUpper = '/^[A-Z][^A-Z]+$/u';
|
||||
$allUpper = '/^[^a-z]+$/u';
|
||||
|
||||
$feedback = [
|
||||
'warning' => $this->getFeedbackWarning($isSoleMatch),
|
||||
'suggestions' => []
|
||||
];
|
||||
|
||||
if (preg_match($startUpper, $this->token)) {
|
||||
$feedback['suggestions'][] = "Capitalization doesn't help very much";
|
||||
} elseif (preg_match($allUpper, $this->token) && mb_strtolower($this->token) != $this->token) {
|
||||
$feedback['suggestions'][] = "All-uppercase is almost as easy to guess as all-lowercase";
|
||||
}
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
public function getFeedbackWarning(bool $isSoleMatch): string
|
||||
{
|
||||
switch ($this->dictionaryName) {
|
||||
case 'passwords':
|
||||
if ($isSoleMatch && !$this->l33t && !$this->reversed) {
|
||||
if ($this->rank <= 10) {
|
||||
return 'This is a top-10 common password';
|
||||
} elseif ($this->rank <= 100) {
|
||||
return 'This is a top-100 common password';
|
||||
} else {
|
||||
return 'This is a very common password';
|
||||
}
|
||||
} elseif ($this->getGuessesLog10() <= 4) {
|
||||
return 'This is similar to a commonly used password';
|
||||
}
|
||||
break;
|
||||
case 'english_wikipedia':
|
||||
if ($isSoleMatch) {
|
||||
return 'A word by itself is easy to guess';
|
||||
}
|
||||
break;
|
||||
case 'surnames':
|
||||
case 'male_names':
|
||||
case 'female_names':
|
||||
if ($isSoleMatch) {
|
||||
return 'Names and surnames by themselves are easy to guess';
|
||||
} else {
|
||||
return 'Common names and surnames are easy to guess';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to find the provided password (as well as all possible substrings) in a dictionary.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $dict
|
||||
* @return array
|
||||
*/
|
||||
protected static function dictionaryMatch(string $password, array $dict): array
|
||||
{
|
||||
$result = [];
|
||||
$length = mb_strlen($password);
|
||||
|
||||
$pw_lower = mb_strtolower($password);
|
||||
|
||||
foreach (range(0, $length - 1) as $i) {
|
||||
foreach (range($i, $length - 1) as $j) {
|
||||
$word = mb_substr($pw_lower, $i, $j - $i + 1);
|
||||
|
||||
if (isset($dict[$word])) {
|
||||
$result[] = [
|
||||
'begin' => $i,
|
||||
'end' => $j,
|
||||
'token' => mb_substr($password, $i, $j - $i + 1),
|
||||
'matched_word' => $word,
|
||||
'rank' => $dict[$word],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load ranked frequency dictionaries.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function getRankedDictionaries(): array
|
||||
{
|
||||
if (empty(self::$rankedDictionaries)) {
|
||||
$json = file_get_contents(dirname(__FILE__) . '/frequency_lists.json');
|
||||
$data = json_decode($json, true);
|
||||
|
||||
$rankedLists = [];
|
||||
foreach ($data as $name => $words) {
|
||||
$rankedLists[$name] = array_combine($words, range(1, count($words)));
|
||||
}
|
||||
self::$rankedDictionaries = $rankedLists;
|
||||
}
|
||||
|
||||
return self::$rankedDictionaries;
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
$guesses = $this->rank;
|
||||
$guesses *= $this->getUppercaseVariations();
|
||||
|
||||
return $guesses;
|
||||
}
|
||||
|
||||
protected function getUppercaseVariations(): float
|
||||
{
|
||||
$word = $this->token;
|
||||
if (preg_match(self::ALL_LOWER, $word) || mb_strtolower($word) === $word) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// a capitalized word is the most common capitalization scheme,
|
||||
// so it only doubles the search space (uncapitalized + capitalized).
|
||||
// allcaps and end-capitalized are common enough too, underestimate as 2x factor to be safe.
|
||||
foreach (array(self::START_UPPER, self::END_UPPER, self::ALL_UPPER) as $regex) {
|
||||
if (preg_match($regex, $word)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise calculate the number of ways to capitalize U+L uppercase+lowercase letters
|
||||
// with U uppercase letters or less. or, if there's more uppercase than lower (for eg. PASSwORD),
|
||||
// the number of ways to lowercase U+L letters with L lowercase letters or less.
|
||||
$uppercase = count(array_filter(preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY), 'ctype_upper'));
|
||||
$lowercase = count(array_filter(preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY), 'ctype_lower'));
|
||||
|
||||
$variations = 0;
|
||||
for ($i = 1; $i <= min($uppercase, $lowercase); $i++) {
|
||||
$variations += Binomial::binom($uppercase + $lowercase, $i);
|
||||
}
|
||||
return $variations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
use ZxcvbnPhp\Math\Binomial;
|
||||
|
||||
/**
|
||||
* Class L33tMatch extends DictionaryMatch to translate l33t into dictionary words for matching.
|
||||
* @package ZxcvbnPhp\Matchers
|
||||
*/
|
||||
class L33tMatch extends DictionaryMatch
|
||||
{
|
||||
/** @var array An array of substitutions made to get from the token to the dictionary word. */
|
||||
public $sub = [];
|
||||
|
||||
/** @var string A user-readable string that shows which substitutions were detected. */
|
||||
public $subDisplay;
|
||||
|
||||
/** @var bool Whether or not the token contained l33t substitutions. */
|
||||
public $l33t = true;
|
||||
|
||||
/**
|
||||
* Match occurences of l33t words in password to dictionary words.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @param array $rankedDictionaries
|
||||
* @return L33tMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = [], array $rankedDictionaries = []): array
|
||||
{
|
||||
// Translate l33t password and dictionary match the translated password.
|
||||
$maps = array_filter(static::getL33tSubstitutions(static::getL33tSubtable($password)));
|
||||
if (empty($maps)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
if (!$rankedDictionaries) {
|
||||
$rankedDictionaries = static::getRankedDictionaries();
|
||||
}
|
||||
|
||||
foreach ($maps as $map) {
|
||||
$translatedWord = static::translate($password, $map);
|
||||
|
||||
/** @var L33tMatch[] $results */
|
||||
$results = parent::match($translatedWord, $userInputs, $rankedDictionaries);
|
||||
foreach ($results as $match) {
|
||||
$token = mb_substr($password, $match->begin, $match->end - $match->begin + 1);
|
||||
|
||||
# only return the matches that contain an actual substitution
|
||||
if (mb_strtolower($token) === $match->matchedWord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
# filter single-character l33t matches to reduce noise.
|
||||
# otherwise '1' matches 'i', '4' matches 'a', both very common English words
|
||||
# with low dictionary rank.
|
||||
if (mb_strlen($token) === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$display = [];
|
||||
foreach ($map as $i => $t) {
|
||||
if (mb_strpos($token, (string)$i) !== false) {
|
||||
$match->sub[$i] = $t;
|
||||
$display[] = "$i -> $t";
|
||||
}
|
||||
}
|
||||
$match->token = $token;
|
||||
$match->subDisplay = implode(', ', $display);
|
||||
|
||||
$matches[] = $match;
|
||||
}
|
||||
}
|
||||
|
||||
Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @param string $token
|
||||
* @param array $params An array with keys: [sub, sub_display].
|
||||
*/
|
||||
public function __construct(string $password, int $begin, int $end, string $token, array $params = [])
|
||||
{
|
||||
parent::__construct($password, $begin, $end, $token, $params);
|
||||
if (!empty($params)) {
|
||||
$this->sub = $params['sub'] ?? [];
|
||||
$this->subDisplay = $params['sub_display'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
$feedback = parent::getFeedback($isSoleMatch);
|
||||
|
||||
$feedback['suggestions'][] = "Predictable substitutions like '@' instead of 'a' don't help very much";
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @param array $map
|
||||
* @return string
|
||||
*/
|
||||
protected static function translate(string $string, array $map): string
|
||||
{
|
||||
return str_replace(array_keys($map), array_values($map), $string);
|
||||
}
|
||||
|
||||
protected static function getL33tTable(): array
|
||||
{
|
||||
return [
|
||||
'a' => ['4', '@'],
|
||||
'b' => ['8'],
|
||||
'c' => ['(', '{', '[', '<'],
|
||||
'e' => ['3'],
|
||||
'g' => ['6', '9'],
|
||||
'i' => ['1', '!', '|'],
|
||||
'l' => ['1', '|', '7'],
|
||||
'o' => ['0'],
|
||||
's' => ['$', '5'],
|
||||
't' => ['+', '7'],
|
||||
'x' => ['%'],
|
||||
'z' => ['2'],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function getL33tSubtable(string $password): array
|
||||
{
|
||||
// The preg_split call below is a multibyte compatible version of str_split
|
||||
$passwordChars = array_unique(preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY));
|
||||
|
||||
$subTable = [];
|
||||
|
||||
$table = static::getL33tTable();
|
||||
foreach ($table as $letter => $substitutions) {
|
||||
foreach ($substitutions as $sub) {
|
||||
if (in_array($sub, $passwordChars)) {
|
||||
$subTable[$letter][] = $sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $subTable;
|
||||
}
|
||||
|
||||
protected static function getL33tSubstitutions(array $subtable): array
|
||||
{
|
||||
$keys = array_keys($subtable);
|
||||
$substitutions = self::substitutionTableHelper($subtable, $keys, [[]]);
|
||||
|
||||
// Converts the substitution arrays from [ [a, b], [c, d] ] to [ a => b, c => d ]
|
||||
$substitutions = array_map(function (array $subArray): array {
|
||||
return array_combine(array_column($subArray, 0), array_column($subArray, 1));
|
||||
}, $substitutions);
|
||||
|
||||
return $substitutions;
|
||||
}
|
||||
|
||||
protected static function substitutionTableHelper(array $table, array $keys, array $subs): array
|
||||
{
|
||||
if (empty($keys)) {
|
||||
return $subs;
|
||||
}
|
||||
|
||||
$firstKey = array_shift($keys);
|
||||
$otherKeys = $keys;
|
||||
$nextSubs = [];
|
||||
|
||||
foreach ($table[$firstKey] as $l33tCharacter) {
|
||||
foreach ($subs as $sub) {
|
||||
$dupL33tIndex = false;
|
||||
foreach ($sub as $index => $char) {
|
||||
if ($char[0] === $l33tCharacter) {
|
||||
$dupL33tIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dupL33tIndex === false) {
|
||||
$subExtension = $sub;
|
||||
$subExtension[] = [$l33tCharacter, $firstKey];
|
||||
$nextSubs[] = $subExtension;
|
||||
} else {
|
||||
$subAlternative = $sub;
|
||||
array_splice($subAlternative, $dupL33tIndex, 1);
|
||||
$subAlternative[] = [$l33tCharacter, $firstKey];
|
||||
$nextSubs[] = $sub;
|
||||
$nextSubs[] = $subAlternative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$nextSubs = array_unique($nextSubs, SORT_REGULAR);
|
||||
return self::substitutionTableHelper($table, $otherKeys, $nextSubs);
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
return parent::getRawGuesses() * $this->getL33tVariations();
|
||||
}
|
||||
|
||||
protected function getL33tVariations(): float
|
||||
{
|
||||
$variations = 1;
|
||||
|
||||
foreach ($this->sub as $substitution => $letter) {
|
||||
$characters = preg_split('//u', mb_strtolower($this->token), -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
$subbed = count(array_filter($characters, function ($character) use ($substitution) {
|
||||
return (string)$character === (string)$substitution;
|
||||
}));
|
||||
$unsubbed = count(array_filter($characters, function ($character) use ($letter) {
|
||||
return (string)$character === (string)$letter;
|
||||
}));
|
||||
|
||||
if ($subbed === 0 || $unsubbed === 0) {
|
||||
// for this sub, password is either fully subbed (444) or fully unsubbed (aaa)
|
||||
// treat that as doubling the space (attacker needs to try fully subbed chars in addition to
|
||||
// unsubbed.)
|
||||
$variations *= 2;
|
||||
} else {
|
||||
$possibilities = 0;
|
||||
for ($i = 1; $i <= min($subbed, $unsubbed); $i++) {
|
||||
$possibilities += Binomial::binom($subbed + $unsubbed, $i);
|
||||
}
|
||||
$variations *= $possibilities;
|
||||
}
|
||||
}
|
||||
return $variations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
interface MatchInterface
|
||||
{
|
||||
/**
|
||||
* Match this password.
|
||||
*
|
||||
* @param string $password Password to check for match
|
||||
* @param array $userInputs Array of values related to the user (optional)
|
||||
* @code array('Alice Smith')
|
||||
* @endcode
|
||||
*
|
||||
* @return array|BaseMatch[] Array of Match objects
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = []): array;
|
||||
|
||||
public function getGuesses(): float;
|
||||
|
||||
public function getGuessesLog10(): float;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
use ZxcvbnPhp\Scorer;
|
||||
|
||||
class RepeatMatch extends BaseMatch
|
||||
{
|
||||
public const GREEDY_MATCH = '/(.+)\1+/u';
|
||||
public const LAZY_MATCH = '/(.+?)\1+/u';
|
||||
public const ANCHORED_LAZY_MATCH = '/^(.+?)\1+$/u';
|
||||
|
||||
public $pattern = 'repeat';
|
||||
|
||||
/** @var MatchInterface[] An array of matches for the repeated section itself. */
|
||||
public $baseMatches = [];
|
||||
|
||||
/** @var int The number of guesses required for the repeated section itself. */
|
||||
public $baseGuesses;
|
||||
|
||||
/** @var int The number of times the repeated section is repeated. */
|
||||
public $repeatCount;
|
||||
|
||||
/** @var string The string that was repeated in the token. */
|
||||
public $repeatedChar;
|
||||
|
||||
/**
|
||||
* Match 3 or more repeated characters.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @return RepeatMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = []): array
|
||||
{
|
||||
$matches = [];
|
||||
$lastIndex = 0;
|
||||
|
||||
while ($lastIndex < mb_strlen($password)) {
|
||||
$greedyMatches = self::findAll($password, self::GREEDY_MATCH, $lastIndex);
|
||||
$lazyMatches = self::findAll($password, self::LAZY_MATCH, $lastIndex);
|
||||
|
||||
if (empty($greedyMatches)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (mb_strlen($greedyMatches[0][0]['token']) > mb_strlen($lazyMatches[0][0]['token'])) {
|
||||
$match = $greedyMatches[0];
|
||||
preg_match(self::ANCHORED_LAZY_MATCH, $match[0]['token'], $anchoredMatch);
|
||||
$repeatedChar = $anchoredMatch[1];
|
||||
} else {
|
||||
$match = $lazyMatches[0];
|
||||
$repeatedChar = $match[1]['token'];
|
||||
}
|
||||
|
||||
$scorer = new Scorer();
|
||||
$matcher = new Matcher();
|
||||
|
||||
$baseAnalysis = $scorer->getMostGuessableMatchSequence($repeatedChar, $matcher->getMatches($repeatedChar));
|
||||
$baseMatches = $baseAnalysis['sequence'];
|
||||
$baseGuesses = $baseAnalysis['guesses'];
|
||||
|
||||
$repeatCount = mb_strlen($match[0]['token']) / mb_strlen($repeatedChar);
|
||||
|
||||
$matches[] = new static(
|
||||
$password,
|
||||
$match[0]['begin'],
|
||||
$match[0]['end'],
|
||||
$match[0]['token'],
|
||||
[
|
||||
'repeated_char' => $repeatedChar,
|
||||
'base_guesses' => $baseGuesses,
|
||||
'base_matches' => $baseMatches,
|
||||
'repeat_count' => $repeatCount,
|
||||
]
|
||||
);
|
||||
|
||||
$lastIndex = $match[0]['end'] + 1;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
$warning = mb_strlen($this->repeatedChar) == 1
|
||||
? 'Repeats like "aaa" are easy to guess'
|
||||
: 'Repeats like "abcabcabc" are only slightly harder to guess than "abc"';
|
||||
|
||||
return [
|
||||
'warning' => $warning,
|
||||
'suggestions' => [
|
||||
'Avoid repeated words and characters',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @param string $token
|
||||
* @param array $params An array with keys: [repeated_char, base_guesses, base_matches, repeat_count].
|
||||
*/
|
||||
public function __construct(string $password, int $begin, int $end, string $token, array $params = [])
|
||||
{
|
||||
parent::__construct($password, $begin, $end, $token);
|
||||
if (!empty($params)) {
|
||||
$this->repeatedChar = $params['repeated_char'] ?? '';
|
||||
$this->baseGuesses = $params['base_guesses'] ?? 0;
|
||||
$this->baseMatches = $params['base_matches'] ?? [];
|
||||
$this->repeatCount = $params['repeat_count'] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
return $this->baseGuesses * $this->repeatCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
|
||||
class ReverseDictionaryMatch extends DictionaryMatch
|
||||
{
|
||||
/** @var bool Whether or not the matched word was reversed in the token. */
|
||||
public $reversed = true;
|
||||
|
||||
/**
|
||||
* Match occurences of reversed dictionary words in password.
|
||||
*
|
||||
* @param $password
|
||||
* @param array $userInputs
|
||||
* @param array $rankedDictionaries
|
||||
* @return ReverseDictionaryMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = [], array $rankedDictionaries = []): array
|
||||
{
|
||||
/** @var ReverseDictionaryMatch[] $matches */
|
||||
$matches = parent::match(self::mbStrRev($password), $userInputs, $rankedDictionaries);
|
||||
foreach ($matches as $match) {
|
||||
$tempBegin = $match->begin;
|
||||
|
||||
// Change the token, password and [begin, end] values to match the original password
|
||||
$match->token = self::mbStrRev($match->token);
|
||||
$match->password = self::mbStrRev($match->password);
|
||||
$match->begin = mb_strlen($password) - 1 - $match->end;
|
||||
$match->end = mb_strlen($password) - 1 - $tempBegin;
|
||||
}
|
||||
Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
|
||||
return $matches;
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
return parent::getRawGuesses() * 2;
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
$feedback = parent::getFeedback($isSoleMatch);
|
||||
|
||||
if (mb_strlen($this->token) >= 4) {
|
||||
$feedback['suggestions'][] = "Reversed words aren't much harder to guess";
|
||||
}
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
public static function mbStrRev(string $string, string $encoding = null): string
|
||||
{
|
||||
if ($encoding === null) {
|
||||
$encoding = mb_detect_encoding($string) ?: 'UTF-8';
|
||||
}
|
||||
$length = mb_strlen($string, $encoding);
|
||||
$reversed = '';
|
||||
while ($length-- > 0) {
|
||||
$reversed .= mb_substr($string, $length, 1, $encoding);
|
||||
}
|
||||
|
||||
return $reversed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
|
||||
class SequenceMatch extends BaseMatch
|
||||
{
|
||||
public const MAX_DELTA = 5;
|
||||
|
||||
public $pattern = 'sequence';
|
||||
|
||||
/** @var string The name of the detected sequence. */
|
||||
public $sequenceName;
|
||||
|
||||
/** @var int The number of characters in the complete sequence space. */
|
||||
public $sequenceSpace;
|
||||
|
||||
/** @var bool True if the sequence is ascending, and false if it is descending. */
|
||||
public $ascending;
|
||||
|
||||
/**
|
||||
* Match sequences of three or more characters.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @return SequenceMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = []): array
|
||||
{
|
||||
$matches = [];
|
||||
$passwordLength = mb_strlen($password);
|
||||
|
||||
if ($passwordLength <= 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$begin = 0;
|
||||
$lastDelta = null;
|
||||
|
||||
for ($index = 1; $index < $passwordLength; $index++) {
|
||||
$delta = mb_ord(mb_substr($password, $index, 1)) - mb_ord(mb_substr($password, $index - 1, 1));
|
||||
if ($lastDelta === null) {
|
||||
$lastDelta = $delta;
|
||||
}
|
||||
if ($lastDelta === $delta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
static::findSequenceMatch($password, $begin, $index - 1, $lastDelta, $matches);
|
||||
$begin = $index - 1;
|
||||
$lastDelta = $delta;
|
||||
}
|
||||
|
||||
static::findSequenceMatch($password, $begin, $passwordLength - 1, $lastDelta, $matches);
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
public static function findSequenceMatch(string $password, int $begin, int $end, int $delta, array &$matches)
|
||||
{
|
||||
if ($end - $begin > 1 || abs($delta) === 1) {
|
||||
if (abs($delta) > 0 && abs($delta) <= self::MAX_DELTA) {
|
||||
$token = mb_substr($password, $begin, $end - $begin + 1);
|
||||
if (preg_match('/^[a-z]+$/u', $token)) {
|
||||
$sequenceName = 'lower';
|
||||
$sequenceSpace = 26;
|
||||
} elseif (preg_match('/^[A-Z]+$/u', $token)) {
|
||||
$sequenceName = 'upper';
|
||||
$sequenceSpace = 26;
|
||||
} elseif (preg_match('/^\d+$/u', $token)) {
|
||||
$sequenceName = 'digits';
|
||||
$sequenceSpace = 10;
|
||||
} else {
|
||||
$sequenceName = 'unicode';
|
||||
$sequenceSpace = 26;
|
||||
}
|
||||
|
||||
$matches[] = new static($password, $begin, $end, $token, [
|
||||
'sequenceName' => $sequenceName,
|
||||
'sequenceSpace' => $sequenceSpace,
|
||||
'ascending' => $delta > 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
return [
|
||||
'warning' => "Sequences like abc or 6543 are easy to guess",
|
||||
'suggestions' => [
|
||||
'Avoid sequences'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @param string $token
|
||||
* @param array $params An array with keys: [sequenceName, sequenceSpace, ascending].
|
||||
*/
|
||||
public function __construct(string $password, int $begin, int $end, string $token, array $params = [])
|
||||
{
|
||||
parent::__construct($password, $begin, $end, $token);
|
||||
if (!empty($params)) {
|
||||
$this->sequenceName = $params['sequenceName'] ?? '';
|
||||
$this->sequenceSpace = $params['sequenceSpace'] ?? 0;
|
||||
$this->ascending = $params['ascending'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
$firstCharacter = mb_substr($this->token, 0, 1);
|
||||
$guesses = 0;
|
||||
|
||||
if (in_array($firstCharacter, array('a', 'A', 'z', 'Z', '0', '1', '9'), true)) {
|
||||
$guesses += 4; // lower guesses for obvious starting points
|
||||
} elseif (ctype_digit($firstCharacter)) {
|
||||
$guesses += 10; // digits
|
||||
} else {
|
||||
// could give a higher base for uppercase,
|
||||
// assigning 26 to both upper and lower sequences is more conservative
|
||||
$guesses += 26;
|
||||
}
|
||||
|
||||
if (!$this->ascending) {
|
||||
// need to try a descending sequence in addition to every ascending sequence ->
|
||||
// 2x guesses
|
||||
$guesses *= 2;
|
||||
}
|
||||
|
||||
return $guesses * mb_strlen($this->token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
use ZxcvbnPhp\Math\Binomial;
|
||||
|
||||
class SpatialMatch extends BaseMatch
|
||||
{
|
||||
public const SHIFTED_CHARACTERS = '~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?';
|
||||
|
||||
// Preset properties since adjacency graph is constant for qwerty keyboard and keypad.
|
||||
public const KEYBOARD_STARTING_POSITION = 94;
|
||||
public const KEYPAD_STARTING_POSITION = 15;
|
||||
public const KEYBOARD_AVERAGE_DEGREES = 4.5957446809; // 432 / 94
|
||||
public const KEYPAD_AVERAGE_DEGREES = 5.0666666667; // 76 / 15
|
||||
|
||||
public $pattern = 'spatial';
|
||||
|
||||
/** @var int The number of characters the shift key was held for in the token. */
|
||||
public $shiftedCount;
|
||||
|
||||
/** @var int The number of turns on the keyboard required to complete the token. */
|
||||
public $turns;
|
||||
|
||||
/** @var string The keyboard layout that the token is a spatial match on. */
|
||||
public $graph;
|
||||
|
||||
/** @var array A cache of the adjacency_graphs json file */
|
||||
protected static $adjacencyGraphs = [];
|
||||
|
||||
/**
|
||||
* Match spatial patterns based on keyboard layouts (e.g. qwerty, dvorak, keypad).
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @param array $graphs
|
||||
* @return SpatialMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = [], array $graphs = []): array
|
||||
{
|
||||
|
||||
$matches = [];
|
||||
if (!$graphs) {
|
||||
$graphs = static::getAdjacencyGraphs();
|
||||
}
|
||||
foreach ($graphs as $name => $graph) {
|
||||
$results = static::graphMatch($password, $graph, $name);
|
||||
foreach ($results as $result) {
|
||||
$result['graph'] = $name;
|
||||
$matches[] = new static($password, $result['begin'], $result['end'], $result['token'], $result);
|
||||
}
|
||||
}
|
||||
Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
|
||||
return $matches;
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
$warning = $this->turns == 1
|
||||
? 'Straight rows of keys are easy to guess'
|
||||
: 'Short keyboard patterns are easy to guess';
|
||||
|
||||
return [
|
||||
'warning' => $warning,
|
||||
'suggestions' => [
|
||||
'Use a longer keyboard pattern with more turns'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @param string $token
|
||||
* @param array $params An array with keys: [graph (required), shifted_count, turns].
|
||||
*/
|
||||
public function __construct(string $password, int $begin, int $end, string $token, array $params = [])
|
||||
{
|
||||
parent::__construct($password, $begin, $end, $token);
|
||||
$this->graph = $params['graph'];
|
||||
if (!empty($params)) {
|
||||
$this->shiftedCount = $params['shifted_count'] ?? null;
|
||||
$this->turns = $params['turns'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match spatial patterns in a adjacency graph.
|
||||
* @param string $password
|
||||
* @param array $graph
|
||||
* @param string $graphName
|
||||
* @return array
|
||||
*/
|
||||
protected static function graphMatch(string $password, array $graph, string $graphName): array
|
||||
{
|
||||
$result = [];
|
||||
$i = 0;
|
||||
|
||||
$passwordLength = mb_strlen($password);
|
||||
|
||||
while ($i < $passwordLength - 1) {
|
||||
$j = $i + 1;
|
||||
$lastDirection = null;
|
||||
$turns = 0;
|
||||
$shiftedCount = 0;
|
||||
|
||||
// Check if the initial character is shifted
|
||||
if ($graphName === 'qwerty' || $graphName === 'dvorak') {
|
||||
if (mb_strpos(self::SHIFTED_CHARACTERS, mb_substr($password, $i, 1)) !== false) {
|
||||
$shiftedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
$prevChar = mb_substr($password, $j - 1, 1);
|
||||
$found = false;
|
||||
$curDirection = -1;
|
||||
$adjacents = $graph[$prevChar] ?? [];
|
||||
|
||||
// Consider growing pattern by one character if j hasn't gone over the edge.
|
||||
if ($j < $passwordLength) {
|
||||
$curChar = mb_substr($password, $j, 1);
|
||||
foreach ($adjacents as $adj) {
|
||||
$curDirection += 1;
|
||||
if ($adj === null) {
|
||||
continue;
|
||||
}
|
||||
$curCharPos = static::indexOf($adj, $curChar);
|
||||
if ($curCharPos !== -1) {
|
||||
$found = true;
|
||||
$foundDirection = $curDirection;
|
||||
|
||||
if ($curCharPos === 1) {
|
||||
// index 1 in the adjacency means the key is shifted, 0 means unshifted: A vs a, % vs 5, etc.
|
||||
// for example, 'q' is adjacent to the entry '2@'. @ is shifted w/ index 1, 2 is unshifted.
|
||||
$shiftedCount += 1;
|
||||
}
|
||||
if ($lastDirection !== $foundDirection) {
|
||||
// adding a turn is correct even in the initial case when last_direction is null:
|
||||
// every spatial pattern starts with a turn.
|
||||
$turns += 1;
|
||||
$lastDirection = $foundDirection;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the current pattern continued, extend j and try to grow again
|
||||
if ($found) {
|
||||
$j += 1;
|
||||
} else {
|
||||
// otherwise push the pattern discovered so far, if any...
|
||||
|
||||
// Ignore length 1 or 2 chains.
|
||||
if ($j - $i > 2) {
|
||||
$result[] = [
|
||||
'begin' => $i,
|
||||
'end' => $j - 1,
|
||||
'token' => mb_substr($password, $i, $j - $i),
|
||||
'turns' => $turns,
|
||||
'shifted_count' => $shiftedCount
|
||||
];
|
||||
}
|
||||
// ...and then start a new search for the rest of the password.
|
||||
$i = $j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index of a string a character first
|
||||
*
|
||||
* @param string $string
|
||||
* @param string $char
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected static function indexOf(string $string, string $char): int
|
||||
{
|
||||
$pos = mb_strpos($string, $char);
|
||||
return ($pos === false ? -1 : $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load adjacency graphs.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getAdjacencyGraphs(): array
|
||||
{
|
||||
if (empty(self::$adjacencyGraphs)) {
|
||||
$json = file_get_contents(dirname(__FILE__) . '/adjacency_graphs.json');
|
||||
$data = json_decode($json, true);
|
||||
|
||||
// This seems pointless, but the data file is not guaranteed to be in any particular order.
|
||||
// We want to be in the exact order below so as to match most closely with upstream, because when a match
|
||||
// can be found in multiple graphs (such as 789), the one that's listed first is that one that will be picked.
|
||||
$data = [
|
||||
'qwerty' => $data['qwerty'],
|
||||
'dvorak' => $data['dvorak'],
|
||||
'keypad' => $data['keypad'],
|
||||
'mac_keypad' => $data['mac_keypad'],
|
||||
];
|
||||
self::$adjacencyGraphs = $data;
|
||||
}
|
||||
|
||||
return self::$adjacencyGraphs;
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
if ($this->graph === 'qwerty' || $this->graph === 'dvorak') {
|
||||
$startingPosition = self::KEYBOARD_STARTING_POSITION;
|
||||
$averageDegree = self::KEYBOARD_AVERAGE_DEGREES;
|
||||
} else {
|
||||
$startingPosition = self::KEYPAD_STARTING_POSITION;
|
||||
$averageDegree = self::KEYPAD_AVERAGE_DEGREES;
|
||||
}
|
||||
|
||||
$guesses = 0;
|
||||
$length = mb_strlen($this->token);
|
||||
$turns = $this->turns;
|
||||
|
||||
// estimate the number of possible patterns w/ length L or less with t turns or less.
|
||||
for ($i = 2; $i <= $length; $i++) {
|
||||
$possibleTurns = min($turns, $i - 1);
|
||||
for ($j = 1; $j <= $possibleTurns; $j++) {
|
||||
$guesses += Binomial::binom($i - 1, $j - 1) * $startingPosition * pow($averageDegree, $j);
|
||||
}
|
||||
}
|
||||
|
||||
// add extra guesses for shifted keys. (% instead of 5, A instead of a.)
|
||||
// math is similar to extra guesses of l33t substitutions in dictionary matches.
|
||||
if ($this->shiftedCount > 0) {
|
||||
$shifted = $this->shiftedCount;
|
||||
$unshifted = $length - $shifted;
|
||||
|
||||
if ($unshifted === 0) {
|
||||
$guesses *= 2;
|
||||
} else {
|
||||
$variations = 0;
|
||||
for ($i = 1; $i <= min($shifted, $unshifted); $i++) {
|
||||
$variations += Binomial::binom($shifted + $unshifted, $i);
|
||||
}
|
||||
$guesses *= $variations;
|
||||
}
|
||||
}
|
||||
|
||||
return $guesses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Matchers;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use ZxcvbnPhp\Matcher;
|
||||
|
||||
class YearMatch extends BaseMatch
|
||||
{
|
||||
public const NUM_YEARS = 119;
|
||||
|
||||
public $pattern = 'regex';
|
||||
public $regexName = 'recent_year';
|
||||
|
||||
/**
|
||||
* Match occurrences of years in a password
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @return YearMatch[]
|
||||
*/
|
||||
public static function match(string $password, array $userInputs = []): array
|
||||
{
|
||||
$matches = [];
|
||||
$groups = static::findAll($password, "/(19\d\d|200\d|201\d)/u");
|
||||
foreach ($groups as $captures) {
|
||||
$matches[] = new static($password, $captures[1]['begin'], $captures[1]['end'], $captures[1]['token']);
|
||||
}
|
||||
Matcher::usortStable($matches, [Matcher::class, 'compareMatches']);
|
||||
return $matches;
|
||||
}
|
||||
|
||||
#[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])]
|
||||
public function getFeedback(bool $isSoleMatch): array
|
||||
{
|
||||
return [
|
||||
'warning' => "Recent years are easy to guess",
|
||||
'suggestions' => [
|
||||
'Avoid recent years',
|
||||
'Avoid years that are associated with you',
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRawGuesses(): float
|
||||
{
|
||||
$yearSpace = abs($this->token - DateMatch::getReferenceYear());
|
||||
return max($yearSpace, DateMatch::MIN_YEAR_SPACE);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math;
|
||||
|
||||
use ZxcvbnPhp\Math\Impl\BinomialProviderPhp73Gmp;
|
||||
use ZxcvbnPhp\Math\Impl\BinomialProviderFloat64;
|
||||
use ZxcvbnPhp\Math\Impl\BinomialProviderInt64;
|
||||
|
||||
class Binomial
|
||||
{
|
||||
private static $provider = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
throw new \LogicException(__CLASS__ . " is static");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate binomial coefficient (n choose k).
|
||||
*
|
||||
* @param int $n
|
||||
* @param int $k
|
||||
* @return float
|
||||
*/
|
||||
public static function binom(int $n, int $k): float
|
||||
{
|
||||
return self::getProvider()->binom($n, $k);
|
||||
}
|
||||
|
||||
public static function getProvider(): BinomialProvider
|
||||
{
|
||||
if (self::$provider === null) {
|
||||
self::$provider = self::initProvider();
|
||||
}
|
||||
|
||||
return self::$provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getUsableProviderClasses(): array
|
||||
{
|
||||
// In order of priority. The first provider with a value of true will be used.
|
||||
$possibleProviderClasses = [
|
||||
BinomialProviderPhp73Gmp::class => function_exists('gmp_binomial'),
|
||||
BinomialProviderInt64::class => PHP_INT_SIZE >= 8,
|
||||
BinomialProviderFloat64::class => PHP_FLOAT_DIG >= 15,
|
||||
];
|
||||
|
||||
$possibleProviderClasses = array_filter($possibleProviderClasses);
|
||||
|
||||
return array_keys($possibleProviderClasses);
|
||||
}
|
||||
|
||||
private static function initProvider(): BinomialProvider
|
||||
{
|
||||
$providerClasses = self::getUsableProviderClasses();
|
||||
|
||||
if (!$providerClasses) {
|
||||
throw new \LogicException("No valid providers");
|
||||
}
|
||||
|
||||
$bestProviderClass = reset($providerClasses);
|
||||
|
||||
return new $bestProviderClass();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math;
|
||||
|
||||
interface BinomialProvider
|
||||
{
|
||||
/**
|
||||
* Calculate binomial coefficient (n choose k).
|
||||
*
|
||||
* @param int $n
|
||||
* @param int $k
|
||||
* @return float
|
||||
*/
|
||||
public function binom(int $n, int $k): float;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math\Impl;
|
||||
|
||||
use ZxcvbnPhp\Math\BinomialProvider;
|
||||
|
||||
abstract class AbstractBinomialProvider implements BinomialProvider
|
||||
{
|
||||
public function binom(int $n, int $k): float
|
||||
{
|
||||
if ($k < 0 || $n < 0) {
|
||||
throw new \DomainException("n and k must be non-negative");
|
||||
}
|
||||
|
||||
if ($k > $n) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// $k and $n - $k will always produce the same value, so use smaller of the two
|
||||
$k = min($k, $n - $k);
|
||||
|
||||
return $this->calculate($n, $k);
|
||||
}
|
||||
|
||||
abstract protected function calculate(int $n, int $k): float;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math\Impl;
|
||||
|
||||
abstract class AbstractBinomialProviderWithFallback extends AbstractBinomialProvider
|
||||
{
|
||||
/**
|
||||
* @var AbstractBinomialProvider|null
|
||||
*/
|
||||
private $fallback = null;
|
||||
|
||||
protected function calculate(int $n, int $k): float
|
||||
{
|
||||
return $this->tryCalculate($n, $k) ?? $this->getFallbackProvider()->calculate($n, $k);
|
||||
}
|
||||
|
||||
abstract protected function tryCalculate(int $n, int $k): ?float;
|
||||
|
||||
abstract protected function initFallbackProvider(): AbstractBinomialProvider;
|
||||
|
||||
protected function getFallbackProvider(): AbstractBinomialProvider
|
||||
{
|
||||
if ($this->fallback === null) {
|
||||
$this->fallback = $this->initFallbackProvider();
|
||||
}
|
||||
|
||||
return $this->fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math\Impl;
|
||||
|
||||
class BinomialProviderFloat64 extends AbstractBinomialProvider
|
||||
{
|
||||
protected function calculate(int $n, int $k): float
|
||||
{
|
||||
$c = 1.0;
|
||||
|
||||
for ($i = 1; $i <= $k; $i++, $n--) {
|
||||
// We're aiming for $c * $n / $i, but the $c * $n part could cause us to lose precision, so use $c / $i * $n instead. The caveat
|
||||
// here is that in order to get a precise answer, we need to minimize the chances of going above ~2^52. This is mitigated
|
||||
// somewhat by dealing with whole part and the remainder separately, but it's not perfect and could overflow in practice, which
|
||||
// would result in a loss of precision.
|
||||
$c = floor($c / $i) * $n + floor(fmod($c, $i) * $n / $i);
|
||||
}
|
||||
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math\Impl;
|
||||
|
||||
use TypeError;
|
||||
|
||||
class BinomialProviderInt64 extends AbstractBinomialProviderWithFallback
|
||||
{
|
||||
protected function initFallbackProvider(): AbstractBinomialProvider
|
||||
{
|
||||
return new BinomialProviderFloat64();
|
||||
}
|
||||
|
||||
protected function tryCalculate(int $n, int $k): ?float
|
||||
{
|
||||
try {
|
||||
$c = 1;
|
||||
|
||||
for ($i = 1; $i <= $k; $i++, $n--) {
|
||||
// We're aiming for $c * $n / $i, but the $c * $n part could overflow, so use $c / $i * $n instead. The caveat here is that in
|
||||
// order to get a precise answer, we need to avoid floats, which means we need to deal with whole part and the remainder
|
||||
// separately.
|
||||
$c = intdiv($c, $i) * $n + intdiv($c % $i * $n, $i);
|
||||
}
|
||||
|
||||
return (float)$c;
|
||||
} catch (TypeError $ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp\Math\Impl;
|
||||
|
||||
class BinomialProviderPhp73Gmp extends AbstractBinomialProvider
|
||||
{
|
||||
/**
|
||||
* @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection
|
||||
* @noinspection PhpComposerExtensionStubsInspection
|
||||
*/
|
||||
protected function calculate(int $n, int $k): float
|
||||
{
|
||||
return (float)gmp_strval(gmp_binomial($n, $k));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp;
|
||||
|
||||
use ZxcvbnPhp\Matchers\Bruteforce;
|
||||
use ZxcvbnPhp\Matchers\BaseMatch;
|
||||
use ZxcvbnPhp\Matchers\MatchInterface;
|
||||
|
||||
/**
|
||||
* scorer - takes a list of potential matches, ranks and evaluates them,
|
||||
* and figures out how many guesses it would take to crack the password
|
||||
*
|
||||
* @see zxcvbn/src/scoring.coffee
|
||||
*/
|
||||
class Scorer
|
||||
{
|
||||
public const MIN_GUESSES_BEFORE_GROWING_SEQUENCE = 10000;
|
||||
public const MIN_SUBMATCH_GUESSES_SINGLE_CHAR = 10;
|
||||
public const MIN_SUBMATCH_GUESSES_MULTI_CHAR = 50;
|
||||
|
||||
protected $password;
|
||||
protected $excludeAdditive;
|
||||
protected $optimal = [];
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------------------
|
||||
* search --- most guessable match sequence -------------------------------------
|
||||
* ------------------------------------------------------------------------------
|
||||
*
|
||||
* takes a sequence of overlapping matches, returns the non-overlapping sequence with
|
||||
* minimum guesses. the following is a O(l_max * (n + m)) dynamic programming algorithm
|
||||
* for a length-n password with m candidate matches. l_max is the maximum optimal
|
||||
* sequence length spanning each prefix of the password. In practice it rarely exceeds 5 and the
|
||||
* search terminates rapidly.
|
||||
*
|
||||
* the optimal "minimum guesses" sequence is here defined to be the sequence that
|
||||
* minimizes the following function:
|
||||
*
|
||||
* g = l! * Product(m.guesses for m in sequence) + D^(l - 1)
|
||||
*
|
||||
* where l is the length of the sequence.
|
||||
*
|
||||
* the factorial term is the number of ways to order l patterns.
|
||||
*
|
||||
* the D^(l-1) term is another length penalty, roughly capturing the idea that an
|
||||
* attacker will try lower-length sequences first before trying length-l sequences.
|
||||
*
|
||||
* for example, consider a sequence that is date-repeat-dictionary.
|
||||
* - an attacker would need to try other date-repeat-dictionary combinations,
|
||||
* hence the product term.
|
||||
* - an attacker would need to try repeat-date-dictionary, dictionary-repeat-date,
|
||||
* ..., hence the factorial term.
|
||||
* - an attacker would also likely try length-1 (dictionary) and length-2 (dictionary-date)
|
||||
* sequences before length-3. assuming at minimum D guesses per pattern type,
|
||||
* D^(l-1) approximates Sum(D^i for i in [1..l-1]
|
||||
*
|
||||
* @param string $password
|
||||
* @param MatchInterface[] $matches
|
||||
* @param bool $excludeAdditive
|
||||
* @return array Returns an array with these keys: [password, guesses, guesses_log10, sequence]
|
||||
*/
|
||||
public function getMostGuessableMatchSequence(string $password, array $matches, bool $excludeAdditive = false): array
|
||||
{
|
||||
$this->password = $password;
|
||||
$this->excludeAdditive = $excludeAdditive;
|
||||
|
||||
$length = mb_strlen($password);
|
||||
$emptyArray = $length > 0 ? array_fill(0, $length, []) : [];
|
||||
|
||||
// partition matches into sublists according to ending index j
|
||||
$matchesByEndIndex = $emptyArray;
|
||||
foreach ($matches as $match) {
|
||||
$matchesByEndIndex[$match->end][] = $match;
|
||||
}
|
||||
|
||||
// small detail: for deterministic output, sort each sublist by i.
|
||||
foreach ($matchesByEndIndex as &$matches) {
|
||||
usort($matches, function ($a, $b) {
|
||||
/** @var $a BaseMatch */
|
||||
/** @var $b BaseMatch */
|
||||
return $a->begin - $b->begin;
|
||||
});
|
||||
}
|
||||
|
||||
$this->optimal = [
|
||||
// optimal.m[k][l] holds final match in the best length-l match sequence covering the
|
||||
// password prefix up to k, inclusive.
|
||||
// if there is no length-l sequence that scores better (fewer guesses) than
|
||||
// a shorter match sequence spanning the same prefix, optimal.m[k][l] is undefined.
|
||||
'm' => $emptyArray,
|
||||
|
||||
// same structure as optimal.m -- holds the product term Prod(m.guesses for m in sequence).
|
||||
// optimal.pi allows for fast (non-looping) updates to the minimization function.
|
||||
'pi' => $emptyArray,
|
||||
|
||||
// same structure as optimal.m -- holds the overall metric.
|
||||
'g' => $emptyArray,
|
||||
];
|
||||
|
||||
for ($k = 0; $k < $length; $k++) {
|
||||
/** @var BaseMatch $match */
|
||||
foreach ($matchesByEndIndex[$k] as $match) {
|
||||
if ($match->begin > 0) {
|
||||
foreach ($this->optimal['m'][$match->begin - 1] as $l => $null) {
|
||||
$l = (int)$l;
|
||||
$this->update($match, $l + 1);
|
||||
}
|
||||
} else {
|
||||
$this->update($match, 1);
|
||||
}
|
||||
}
|
||||
$this->bruteforceUpdate($k);
|
||||
}
|
||||
|
||||
|
||||
if ($length === 0) {
|
||||
$guesses = 1.0;
|
||||
$optimalSequence = [];
|
||||
} else {
|
||||
$optimalSequence = $this->unwind($length);
|
||||
$optimalSequenceLength = count($optimalSequence);
|
||||
$guesses = $this->optimal['g'][$length - 1][$optimalSequenceLength];
|
||||
}
|
||||
|
||||
return [
|
||||
'password' => $password,
|
||||
'guesses' => $guesses,
|
||||
'guesses_log10' => log10($guesses),
|
||||
'sequence' => $optimalSequence,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* helper: considers whether a length-l sequence ending at match m is better (fewer guesses)
|
||||
* than previously encountered sequences, updating state if so.
|
||||
* @param BaseMatch $match
|
||||
* @param int $length
|
||||
*/
|
||||
protected function update(BaseMatch $match, int $length): void
|
||||
{
|
||||
$k = $match->end;
|
||||
|
||||
// Upstream has a call to estimateGuesses for this line (which contains some extra logic), but due to our
|
||||
// object-oriented approach we can just call getGuesses on the match directly.
|
||||
$pi = $match->getGuesses();
|
||||
|
||||
if ($length > 1) {
|
||||
// we're considering a length-l sequence ending with match m:
|
||||
// obtain the product term in the minimization function by multiplying m's guesses
|
||||
// by the product of the length-(l-1) sequence ending just before m, at m.i - 1.
|
||||
$pi *= $this->optimal['pi'][$match->begin - 1][$length - 1];
|
||||
}
|
||||
|
||||
// calculate the minimization func
|
||||
$g = $this->factorial($length) * $pi;
|
||||
if (!$this->excludeAdditive) {
|
||||
$g += pow(self::MIN_GUESSES_BEFORE_GROWING_SEQUENCE, $length - 1);
|
||||
}
|
||||
|
||||
// update state if new best.
|
||||
// first see if any competing sequences covering this prefix, with l or fewer matches,
|
||||
// fare better than this sequence. if so, skip it and return.
|
||||
foreach ($this->optimal['g'][$k] as $competingL => $competingG) {
|
||||
if ($competingL > $length) {
|
||||
continue;
|
||||
}
|
||||
if ($competingG <= $g) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->optimal['g'][$k][$length] = $g;
|
||||
$this->optimal['m'][$k][$length] = $match;
|
||||
$this->optimal['pi'][$k][$length] = $pi;
|
||||
|
||||
// Sort the arrays by key after each insert to match how JavaScript objects work
|
||||
// Failing to do this results in slightly different matches in some scenarios
|
||||
ksort($this->optimal['g'][$k]);
|
||||
ksort($this->optimal['m'][$k]);
|
||||
ksort($this->optimal['pi'][$k]);
|
||||
}
|
||||
|
||||
/**
|
||||
* helper: evaluate bruteforce matches ending at k
|
||||
* @param int $end
|
||||
*/
|
||||
protected function bruteforceUpdate(int $end): void
|
||||
{
|
||||
// see if a single bruteforce match spanning the k-prefix is optimal.
|
||||
$match = $this->makeBruteforceMatch(0, $end);
|
||||
$this->update($match, 1);
|
||||
|
||||
// generate k bruteforce matches, spanning from (i=1, j=k) up to (i=k, j=k).
|
||||
// see if adding these new matches to any of the sequences in optimal[i-1]
|
||||
// leads to new bests.
|
||||
for ($i = 1; $i <= $end; $i++) {
|
||||
$match = $this->makeBruteforceMatch($i, $end);
|
||||
foreach ($this->optimal['m'][$i - 1] as $l => $lastM) {
|
||||
$l = (int)$l;
|
||||
|
||||
// corner: an optimal sequence will never have two adjacent bruteforce matches.
|
||||
// it is strictly better to have a single bruteforce match spanning the same region:
|
||||
// same contribution to the guess product with a lower length.
|
||||
// --> safe to skip those cases.
|
||||
if ($lastM->pattern === 'bruteforce') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->update($match, $l + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* helper: make bruteforce match objects spanning i to j, inclusive.
|
||||
* @param int $begin
|
||||
* @param int $end
|
||||
* @return Bruteforce
|
||||
*/
|
||||
protected function makeBruteforceMatch(int $begin, int $end): Bruteforce
|
||||
{
|
||||
return new Bruteforce($this->password, $begin, $end, mb_substr($this->password, $begin, $end - $begin + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* helper: step backwards through optimal.m starting at the end, constructing the final optimal match sequence.
|
||||
* @param int $n
|
||||
* @return MatchInterface[]
|
||||
*/
|
||||
protected function unwind(int $n): array
|
||||
{
|
||||
$optimalSequence = [];
|
||||
$k = $n - 1;
|
||||
|
||||
// find the final best sequence length and score
|
||||
$l = null;
|
||||
$g = INF;
|
||||
|
||||
foreach ($this->optimal['g'][$k] as $candidateL => $candidateG) {
|
||||
if ($candidateG < $g) {
|
||||
$l = $candidateL;
|
||||
$g = $candidateG;
|
||||
}
|
||||
}
|
||||
|
||||
while ($k >= 0) {
|
||||
$m = $this->optimal['m'][$k][$l];
|
||||
array_unshift($optimalSequence, $m);
|
||||
$k = $m->begin - 1;
|
||||
$l--;
|
||||
}
|
||||
|
||||
return $optimalSequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* unoptimized, called only on small n
|
||||
* @param int $n
|
||||
* @return int
|
||||
*/
|
||||
protected function factorial(int $n): int
|
||||
{
|
||||
if ($n < 2) {
|
||||
return 1;
|
||||
}
|
||||
$f = 1;
|
||||
for ($i = 2; $i <= $n; $i++) {
|
||||
$f *= $i;
|
||||
}
|
||||
return $f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp;
|
||||
|
||||
/**
|
||||
* Feedback - gives some user guidance based on the strength
|
||||
* of a password
|
||||
*
|
||||
* @see zxcvbn/src/time_estimates.coffee
|
||||
*/
|
||||
class TimeEstimator
|
||||
{
|
||||
/**
|
||||
* @param int|float $guesses
|
||||
* @return array
|
||||
*/
|
||||
public function estimateAttackTimes(float $guesses): array
|
||||
{
|
||||
$crack_times_seconds = [
|
||||
'online_throttling_100_per_hour' => $guesses / (100 / 3600),
|
||||
'online_no_throttling_10_per_second' => $guesses / 10,
|
||||
'offline_slow_hashing_1e4_per_second' => $guesses / 1e4,
|
||||
'offline_fast_hashing_1e10_per_second' => $guesses / 1e10
|
||||
];
|
||||
|
||||
$crack_times_display = array_map(
|
||||
[ $this, 'displayTime' ],
|
||||
$crack_times_seconds
|
||||
);
|
||||
|
||||
return [
|
||||
'crack_times_seconds' => $crack_times_seconds,
|
||||
'crack_times_display' => $crack_times_display,
|
||||
'score' => $this->guessesToScore($guesses)
|
||||
];
|
||||
}
|
||||
|
||||
protected function guessesToScore(float $guesses): int
|
||||
{
|
||||
$DELTA = 5;
|
||||
|
||||
if ($guesses < 1e3 + $DELTA) {
|
||||
# risky password: "too guessable"
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($guesses < 1e6 + $DELTA) {
|
||||
# modest protection from throttled online attacks: "very guessable"
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($guesses < 1e8 + $DELTA) {
|
||||
# modest protection from unthrottled online attacks: "somewhat guessable"
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ($guesses < 1e10 + $DELTA) {
|
||||
# modest protection from offline attacks: "safely unguessable"
|
||||
# assuming a salted, slow hash function like bcrypt, scrypt, PBKDF2, argon, etc
|
||||
return 3;
|
||||
}
|
||||
|
||||
# strong protection from offline attacks under same scenario: "very unguessable"
|
||||
return 4;
|
||||
}
|
||||
|
||||
protected function displayTime(float $seconds): string
|
||||
{
|
||||
$callback = function (float $seconds): array {
|
||||
$minute = 60;
|
||||
$hour = $minute * 60;
|
||||
$day = $hour * 24;
|
||||
$month = $day * 31;
|
||||
$year = $month * 12;
|
||||
$century = $year * 100;
|
||||
|
||||
if ($seconds < 1) {
|
||||
return [null, 'less than a second'];
|
||||
}
|
||||
|
||||
if ($seconds < $minute) {
|
||||
$base = round($seconds);
|
||||
return [$base, "$base second"];
|
||||
}
|
||||
|
||||
if ($seconds < $hour) {
|
||||
$base = round($seconds / $minute);
|
||||
return [$base, "$base minute"];
|
||||
}
|
||||
|
||||
if ($seconds < $day) {
|
||||
$base = round($seconds / $hour);
|
||||
return [$base, "$base hour"];
|
||||
}
|
||||
|
||||
if ($seconds < $month) {
|
||||
$base = round($seconds / $day);
|
||||
return [$base, "$base day"];
|
||||
}
|
||||
|
||||
if ($seconds < $year) {
|
||||
$base = round($seconds / $month);
|
||||
return [$base, "$base month"];
|
||||
}
|
||||
|
||||
if ($seconds < $century) {
|
||||
$base = round($seconds / $year);
|
||||
return [$base, "$base year"];
|
||||
}
|
||||
|
||||
return [null, 'centuries'];
|
||||
};
|
||||
|
||||
[$display_num, $display_str] = $callback($seconds);
|
||||
|
||||
if ($display_num > 1) {
|
||||
$display_str .= 's';
|
||||
}
|
||||
|
||||
return $display_str;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ZxcvbnPhp;
|
||||
|
||||
/**
|
||||
* The main entry point.
|
||||
*
|
||||
* @see zxcvbn/src/main.coffee
|
||||
*/
|
||||
class Zxcvbn
|
||||
{
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
protected $matcher;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
protected $scorer;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
protected $timeEstimator;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
protected $feedback;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->matcher = new \ZxcvbnPhp\Matcher();
|
||||
$this->scorer = new \ZxcvbnPhp\Scorer();
|
||||
$this->timeEstimator = new \ZxcvbnPhp\TimeEstimator();
|
||||
$this->feedback = new \ZxcvbnPhp\Feedback();
|
||||
}
|
||||
|
||||
public function addMatcher(string $className): self
|
||||
{
|
||||
$this->matcher->addMatcher($className);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate password strength via non-overlapping minimum entropy patterns.
|
||||
*
|
||||
* @param string $password Password to measure
|
||||
* @param array $userInputs Optional user inputs
|
||||
*
|
||||
* @return array Strength result array with keys:
|
||||
* password
|
||||
* entropy
|
||||
* match_sequence
|
||||
* score
|
||||
*/
|
||||
public function passwordStrength(string $password, array $userInputs = []): array
|
||||
{
|
||||
$timeStart = microtime(true);
|
||||
|
||||
$sanitizedInputs = array_map(
|
||||
function ($input) {
|
||||
return mb_strtolower((string) $input);
|
||||
},
|
||||
$userInputs
|
||||
);
|
||||
|
||||
// Get matches for $password.
|
||||
// Although the coffeescript upstream sets $sanitizedInputs as a property,
|
||||
// doing this immutably makes more sense and is a bit easier
|
||||
$matches = $this->matcher->getMatches($password, $sanitizedInputs);
|
||||
|
||||
$result = $this->scorer->getMostGuessableMatchSequence($password, $matches);
|
||||
$attackTimes = $this->timeEstimator->estimateAttackTimes($result['guesses']);
|
||||
$feedback = $this->feedback->getFeedback($attackTimes['score'], $result['sequence']);
|
||||
|
||||
return array_merge(
|
||||
$result,
|
||||
$attackTimes,
|
||||
[
|
||||
'feedback' => $feedback,
|
||||
'calc_time' => microtime(true) - $timeStart
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user