Added basic scoring class and match interface

This commit is contained in:
Netkas 2021-09-16 17:34:11 -04:00
parent abc545de70
commit 82271fb9e7
9 changed files with 687 additions and 0 deletions

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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)
);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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
];
}
}

View File

@ -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"