Added basic scoring class and match interface
This commit is contained in:
parent
abc545de70
commit
82271fb9e7
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Abstracts;
|
||||
|
||||
|
||||
use Zxcvbn\Interfaces\MatchInterface;
|
||||
|
||||
abstract class BaseMatch implements MatchInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $password;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $begin;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $end;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $token;
|
||||
|
||||
/**
|
||||
* @var
|
||||
*/
|
||||
public $pattern;
|
||||
|
||||
/**
|
||||
* @param $password
|
||||
* @param $begin
|
||||
* @param $end
|
||||
* @param $token
|
||||
*/
|
||||
public function __construct($password, $begin, $end, $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)
|
||||
*/
|
||||
abstract public function getFeedback($isSoleMatch);
|
||||
|
||||
/**
|
||||
* Find all occurences 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, $regex, $offset = 0)
|
||||
{
|
||||
// $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).
|
||||
*
|
||||
* http://www.php.net/manual/en/ref.math.php#57895
|
||||
*
|
||||
* @param $n
|
||||
* @param $k
|
||||
* @return int
|
||||
*/
|
||||
public static function binom($n, $k)
|
||||
{
|
||||
$j = $res = 1;
|
||||
|
||||
if ($k < 0 || $k > $n) {
|
||||
return 0;
|
||||
}
|
||||
if (($n - $k) < $k) {
|
||||
$k = $n - $k;
|
||||
}
|
||||
while ($j <= $k) {
|
||||
$res *= $n--;
|
||||
$res /= $j++;
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
abstract protected function getRawGuesses();
|
||||
|
||||
public function getGuesses()
|
||||
{
|
||||
return max($this->getRawGuesses(), $this->getMinimumGuesses());
|
||||
}
|
||||
|
||||
protected function getMinimumGuesses()
|
||||
{
|
||||
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()
|
||||
{
|
||||
return log10($this->getGuesses());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Abstracts;
|
||||
|
||||
abstract class Score
|
||||
{
|
||||
const MIN_GUESSES_BEFORE_GROWING_SEQUENCE = 10000;
|
||||
|
||||
const MIN_SUBMATCH_GUESSES_SINGLE_CHAR = 10;
|
||||
|
||||
const MIN_SUBMATCH_GUESSES_MULTI_CHAR = 50;
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Classes;
|
||||
|
||||
|
||||
use Zxcvbn\Interfaces\MatchInterface;
|
||||
|
||||
class Matcher
|
||||
{
|
||||
private const DEFAULT_MATCHERS = [];
|
||||
|
||||
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($password, array $userInputs = [])
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom matching class
|
||||
*
|
||||
* @param string $className
|
||||
* @return $this
|
||||
*/
|
||||
public function addMatcher(string $className)
|
||||
{
|
||||
if (!is_a($className, MatchInterface::class, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Matcher class must implement %s', MatchInterface::class));
|
||||
}
|
||||
|
||||
$this->additionalMatchers[$className] = $className;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
public static function compareMatches(BaseMatch $a, BaseMatch $b)
|
||||
{
|
||||
$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()
|
||||
{
|
||||
return array_merge(
|
||||
self::DEFAULT_MATCHERS,
|
||||
array_values($this->additionalMatchers)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Classes\Matchers;
|
||||
|
||||
|
||||
use Zxcvbn\Abstracts\BaseMatch;
|
||||
use Zxcvbn\Abstracts\Score;
|
||||
|
||||
class Bruteforce extends BaseMatch
|
||||
{
|
||||
public const BRUTEFORCE_CARDINALITY = 10;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $pattern = 'bruteforce';
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param array $userInputs
|
||||
* @return Bruteforce[]
|
||||
*/
|
||||
public static function match($password, array $userInputs = []): array
|
||||
{
|
||||
// Matches entire string.
|
||||
$match = new static($password, 0, mb_strlen($password) - 1, $password);
|
||||
return [$match];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isSoleMatch
|
||||
* @return array
|
||||
*/
|
||||
public function getFeedback($isSoleMatch): array
|
||||
{
|
||||
return [
|
||||
'warning' => "",
|
||||
'suggestions' => [
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float|mixed
|
||||
*/
|
||||
public function getRawGuesses()
|
||||
{
|
||||
$guesses = pow(self::BRUTEFORCE_CARDINALITY, mb_strlen($this->token));
|
||||
if ($guesses === INF)
|
||||
{
|
||||
return defined('PHP_FLOAT_MAX') ? PHP_FLOAT_MAX : 1e308;
|
||||
}
|
||||
|
||||
if (mb_strlen($this->token) === 1)
|
||||
{
|
||||
$minGuesses = Score::MIN_SUBMATCH_GUESSES_SINGLE_CHAR + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
$minGuesses = Score::MIN_SUBMATCH_GUESSES_MULTI_CHAR + 1;
|
||||
}
|
||||
|
||||
return max($guesses, $minGuesses);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
/** @noinspection PhpMissingFieldTypeInspection */
|
||||
|
||||
namespace Zxcvbn\Classes;
|
||||
|
||||
use Zxcvbn\Abstracts\BaseMatch;
|
||||
use Zxcvbn\Abstracts\Score;
|
||||
use Zxcvbn\Classes\Matchers\Bruteforce;
|
||||
use Zxcvbn\Interfaces\MatchInterface;
|
||||
use Zxcvbn\Objects\GuessableMatchSequence;
|
||||
|
||||
class Scorer
|
||||
{
|
||||
protected $password;
|
||||
protected $excludeAdditive;
|
||||
protected $optimal = [];
|
||||
|
||||
/**
|
||||
* @param string $password
|
||||
* @param MatchInterface[] $matches
|
||||
* @param bool $excludeAdditive
|
||||
* @return GuessableMatchSequence Returns an array with these keys: [password, guesses, guesses_log10, sequence]
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
public function getMostGuessableMatchSequence(string $password, array $matches, bool $excludeAdditive): GuessableMatchSequence
|
||||
{
|
||||
$this->password = $password;
|
||||
$this->excludeAdditive = $excludeAdditive;
|
||||
$length = mb_strlen($password);
|
||||
$emptyArray = $length > 0 ? array_fill(0, $length, []) : [];
|
||||
$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 = [
|
||||
'm' => $emptyArray,
|
||||
'pi' => $emptyArray,
|
||||
'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;
|
||||
$optimalSequence = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
$optimalSequence = $this->unwind($length);
|
||||
$optimalSequenceLength = count($optimalSequence);
|
||||
$guesses = $this->optimal['g'][$length - 1][$optimalSequenceLength];
|
||||
}
|
||||
|
||||
$ReturnResults = new GuessableMatchSequence();
|
||||
$ReturnResults->Password = $password;
|
||||
$ReturnResults->Guesses = $guesses;
|
||||
$ReturnResults->GuessesLog10 = log10($guesses);
|
||||
$ReturnResults->Sequence = $optimalSequence;
|
||||
|
||||
return $ReturnResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
$k = $match->end;
|
||||
$pi = $match->getGuesses();
|
||||
|
||||
if ($length > 1)
|
||||
{
|
||||
$pi *= $this->optimal['pi'][$match->begin - 1][$length - 1];
|
||||
}
|
||||
|
||||
$g = $this->factorial($length) * $pi;
|
||||
if (!$this->excludeAdditive)
|
||||
{
|
||||
$g += pow(Score::MIN_GUESSES_BEFORE_GROWING_SEQUENCE, $length - 1);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
$match = $this->makeBruteforceMatch(0, $end);
|
||||
$this->update($match, 1);
|
||||
|
||||
for ($i = 1; $i <= $end; $i++)
|
||||
{
|
||||
$match = $this->makeBruteforceMatch($i, $end);
|
||||
foreach ($this->optimal['m'][$i - 1] as $l => $lastM)
|
||||
{
|
||||
$l = (int)$l;
|
||||
|
||||
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,36 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Classes;
|
||||
|
||||
class Utilities
|
||||
{
|
||||
/**
|
||||
* A stable implementation of usort().
|
||||
*
|
||||
* @param array $array
|
||||
* @param callable $value_compare_func
|
||||
* @return bool
|
||||
*/
|
||||
public static function usort(array &$array, callable $value_compare_func)
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Interfaces;
|
||||
|
||||
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 Array of Match objects
|
||||
*/
|
||||
public static function match($password, array $userInputs = []);
|
||||
|
||||
/**
|
||||
* @return integer
|
||||
*/
|
||||
public function getGuesses();
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getGuessesLog10();
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Zxcvbn\Objects;
|
||||
|
||||
class GuessableMatchSequence
|
||||
{
|
||||
public $Password;
|
||||
|
||||
public $Guesses;
|
||||
|
||||
public $GuessesLog10;
|
||||
|
||||
public $Sequence;
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'password' => $this->Password,
|
||||
'guesses' => $this->Guesses,
|
||||
'guesses_log10' => $this->GuessesLog10,
|
||||
'sequence' => $this->Sequence
|
||||
];
|
||||
}
|
||||
}
|
|
@ -16,6 +16,38 @@
|
|||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"required": true,
|
||||
"file": "Abstracts/BaseMatch.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Abstracts/Score.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Classes/Matcher.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Classes/Matchers/Bruteforce.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Classes/Scorer.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Classes/Utilities.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Interfaces/MatchInterface.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "Objects/GuessableMatchSequence.php"
|
||||
},
|
||||
{
|
||||
"required": true,
|
||||
"file": "zxcvbn.php"
|
||||
|
|
Loading…
Reference in New Issue