This commit is contained in:
cutemeli
2025-12-22 10:35:30 +00:00
parent 0bfc6c8425
commit 5ce7ca2c5d
38927 changed files with 0 additions and 4594700 deletions

View File

@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2013-2014 Benjamin Jeavons
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,67 +0,0 @@
Zxcvbn-PHP is a password strength estimator using pattern matching and minimum entropy calculation. Zxcvbn-PHP is based on the [the Javascript zxcvbn project](https://github.com/dropbox/zxcvbn) from [Dropbox and @lowe](https://blogs.dropbox.com/tech/2012/04/zxcvbn-realistic-password-strength-estimation/). "zxcvbn" is bad password, just like "qwerty" and "123456".
>zxcvbn attempts to give sound password advice through pattern matching and conservative entropy calculations. It finds 10k common passwords, common American names and surnames, common English words, and common patterns like dates, repeats (aaa), sequences (abcd), and QWERTY patterns.
[![Build Status](https://travis-ci.org/bjeavons/zxcvbn-php.png?branch=master)](https://travis-ci.org/bjeavons/zxcvbn-php)
[![Coverage Status](https://coveralls.io/repos/github/bjeavons/zxcvbn-php/badge.svg?branch=master)](https://coveralls.io/github/bjeavons/zxcvbn-php?branch=master)
[![Latest Stable Version](https://poser.pugx.org/bjeavons/zxcvbn-php/v/stable)](https://packagist.org/packages/bjeavons/zxcvbn-php)
[![License](https://poser.pugx.org/bjeavons/zxcvbn-php/license)](https://packagist.org/packages/bjeavons/zxcvbn-php)
## Installation
The library can be installed with [Composer](http://getcomposer.org) by adding it as a dependency to your composer.json file.
Via the command line run:
`composer require bjeavons/zxcvbn-php`
Or in your composer.json add
```json
{
"require": {
"bjeavons/zxcvbn-php": "^1.0"
}
}
```
Then run `composer update` on the command line and include the
autoloader in your PHP scripts so that the ZxcvbnPhp class is available.
```php
require_once 'vendor/autoload.php';
```
## Usage
```php
use ZxcvbnPhp\Zxcvbn;
$userData = [
'Marco',
'marco@example.com'
];
$zxcvbn = new Zxcvbn();
$weak = $zxcvbn->passwordStrength('password', $userData);
echo $weak['score']; // will print 0
$strong = $zxcvbn->passwordStrength('correct horse battery staple');
echo $strong['score']; // will print 4
echo $weak['feedback']['warning']; // will print user-facing feedback on the password, set only when score <= 2
// $weak['feedback']['suggestions'] may contain user-facing suggestions to improve the score
```
Scores are integers from 0 to 4:
* 0 means the password is extremely guessable (within 10^3 guesses), dictionary words like 'password' or 'mother' score a 0
* 1 is still very guessable (guesses < 10^6), an extra character on a dictionary word can score a 1
* 2 is somewhat guessable (guesses < 10^8), provides some protection from unthrottled online attacks
* 3 is safely unguessable (guesses < 10^10), offers moderate protection from offline slow-hash scenario
* 4 is very unguessable (guesses >= 10^10) and provides strong protection from offline slow-hash scenario
### Acknowledgements
Thanks to:
* @lowe for the original [Javascript Zxcvbn](https://github.com/lowe/zxcvbn)
* [@Dreyer's port](https://github.com/Dreyer/php-zxcvbn) for reference for initial implementation
* [@mkopinsky](https://github.com/mkopinsky) for major updates to keep in sync with upstream scoring

View File

@@ -1,34 +0,0 @@
{
"name": "bjeavons/zxcvbn-php",
"type": "library",
"description": "Realistic password strength estimation PHP library based on Zxcvbn JS",
"keywords": ["zxcvbn","password"],
"homepage": "https://github.com/bjeavons/zxcvbn-php",
"license": "MIT",
"authors": [
{
"name": "See contributors",
"homepage": "https://github.com/bjeavons/zxcvbn-php"
}
],
"require": {
"php": "^7.2 | ^8.0 | ^8.1",
"symfony/polyfill-mbstring": ">=1.3.1",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^8.5",
"php-coveralls/php-coveralls": "*",
"squizlabs/php_codesniffer": "3.*",
"phpstan/phpstan": "^2.0"
},
"suggest": {
"ext-gmp": "Required for optimized binomial calculations (also requires PHP >= 7.3)"
},
"autoload": {
"psr-4": { "ZxcvbnPhp\\": "src/" }
},
"autoload-dev": {
"psr-4": { "ZxcvbnPhp\\Test\\": "test/" }
}
}

View File

@@ -1,135 +0,0 @@
#!/usr/bin/python
import os
import sys
import time
import codecs
import json
from operator import itemgetter
def usage():
return '''
usage:
%s data-dir src/Matchers/frequency_lists.json
generates frequency_lists.json (zxcvbn's ranked dictionary file) from word frequency data.
data-dir should contain frequency counts, as generated by the data-scripts/count_* scripts.
DICTIONARIES controls which frequency data will be included and at maximum how many tokens
per dictionary.
If a token appears in multiple frequency lists, it will only appear once in emitted .json file,
in the dictionary where it has lowest rank.
Short tokens, if rare, are also filtered out. If a token has higher rank than 10**(token.length),
it will be excluded because a bruteforce match would have given it a lower guess score.
A warning will be printed if DICTIONARIES contains a dictionary name that doesn't appear in
passed data dir, or vice-versa.
''' % sys.argv[0]
# maps dict name to num words. None value means "include all words"
DICTIONARIES = dict(
us_tv_and_film = 30000,
english_wikipedia = 30000,
passwords = 30000,
surnames = 10000,
male_names = None,
female_names = None,
)
# returns {list_name: {token: rank}}, as tokens and ranks occur in each file.
def parse_frequency_lists(data_dir):
freq_lists = {}
for filename in os.listdir(data_dir):
freq_list_name, ext = os.path.splitext(filename)
if freq_list_name not in DICTIONARIES:
msg = 'Warning: %s appears in %s directory but not in DICTIONARY settings. Excluding.'
print msg % (freq_list_name, data_dir)
continue
token_to_rank = {}
with codecs.open(os.path.join(data_dir, filename), 'r', 'utf8') as f:
for i, line in enumerate(f):
rank = i + 1 # rank starts at 1
token = line.split()[0]
token_to_rank[token] = rank
freq_lists[freq_list_name] = token_to_rank
for freq_list_name in DICTIONARIES:
if freq_list_name not in freq_lists:
msg = 'Warning: %s appears in DICTIONARY settings but not in %s directory. Excluding.'
print msg % (freq_list, data_dir)
return freq_lists
def is_rare_and_short(token, rank):
return rank >= 10**len(token)
def has_comma_or_double_quote(token, rank, lst_name):
# hax, switch to csv or similar if this excludes too much.
# simple comma joining has the advantage of being easy to process
# client-side w/o needing a lib, and so far this only excludes a few
# very high-rank tokens eg 'ps8,000' at rank 74868 from wikipedia list.
if ',' in token or '"' in token:
return True
return False
def filter_frequency_lists(freq_lists):
'''
filters frequency data according to:
- filter out short tokens if they are too rare.
- filter out tokens if they already appear in another dict
at lower rank.
- cut off final freq_list at limits set in DICTIONARIES, if any.
'''
filtered_token_and_rank = {} # maps {name: [(token, rank), ...]}
token_count = {} # maps freq list name: current token count.
for name in freq_lists:
filtered_token_and_rank[name] = []
token_count[name] = 0
minimum_rank = {} # maps token -> lowest token rank across all freq lists
minimum_name = {} # maps token -> freq list name with lowest token rank
for name, token_to_rank in freq_lists.iteritems():
for token, rank in token_to_rank.iteritems():
if token not in minimum_rank:
assert token not in minimum_name
minimum_rank[token] = rank
minimum_name[token] = name
else:
assert token in minimum_name
assert minimum_name[token] != name, 'same token occurs multiple times in %s' % name
min_rank = minimum_rank[token]
if rank < min_rank:
minimum_rank[token] = rank
minimum_name[token] = name
for name, token_to_rank in freq_lists.iteritems():
for token, rank in token_to_rank.iteritems():
if minimum_name[token] != name:
continue
if is_rare_and_short(token, rank) or has_comma_or_double_quote(token, rank, name):
continue
filtered_token_and_rank[name].append((token, rank))
token_count[name] += 1
result = {}
for name, token_rank_pairs in filtered_token_and_rank.iteritems():
token_rank_pairs.sort(key=itemgetter(1))
cutoff_limit = DICTIONARIES[name]
if cutoff_limit and len(token_rank_pairs) > cutoff_limit:
token_rank_pairs = token_rank_pairs[:cutoff_limit]
result[name] = [pair[0] for pair in token_rank_pairs] # discard rank post-sort
return result
def to_kv(lst, lst_name):
val = '"%s".split(",")' % ','.join(lst)
return '%s: %s' % (lst_name, val)
def main():
if len(sys.argv) != 3:
print usage()
sys.exit(0)
data_dir, output_file = sys.argv[1:]
unfiltered_freq_lists = parse_frequency_lists(data_dir)
freq_lists = filter_frequency_lists(unfiltered_freq_lists)
with codecs.open(output_file, 'w', 'utf8') as f:
json.dump(freq_lists, f)
if __name__ == '__main__':
main()

View File

@@ -1,105 +0,0 @@
#!/usr/bin/python
import sys
import json as simplejson
def usage():
return '''
constructs adjacency_graphs.json from QWERTY and DVORAK keyboard layouts
usage:
%s src/Matchers/adjacency_graphs.json
''' % sys.argv[0]
qwerty = r'''
`~ 1! 2@ 3# 4$ 5% 6^ 7& 8* 9( 0) -_ =+
qQ wW eE rR tT yY uU iI oO pP [{ ]} \|
aA sS dD fF gG hH jJ kK lL ;: '"
zZ xX cC vV bB nN mM ,< .> /?
'''
dvorak = r'''
`~ 1! 2@ 3# 4$ 5% 6^ 7& 8* 9( 0) [{ ]}
'" ,< .> pP yY fF gG cC rR lL /? =+ \|
aA oO eE uU iI dD hH tT nN sS -_
;: qQ jJ kK xX bB mM wW vV zZ
'''
keypad = r'''
/ * -
7 8 9 +
4 5 6
1 2 3
0 .
'''
mac_keypad = r'''
= / *
7 8 9 -
4 5 6 +
1 2 3
0 .
'''
def get_slanted_adjacent_coords(x, y):
'''
returns the six adjacent coordinates on a standard keyboard, where each row is slanted to the
right from the last. adjacencies are clockwise, starting with key to the left, then two keys
above, then right key, then two keys below. (that is, only near-diagonal keys are adjacent,
so g's coordinate is adjacent to those of t,y,b,v, but not those of r,u,n,c.)
'''
return [(x-1, y), (x, y-1), (x+1, y-1), (x+1, y), (x, y+1), (x-1, y+1)]
def get_aligned_adjacent_coords(x, y):
'''
returns the nine clockwise adjacent coordinates on a keypad, where each row is vert aligned.
'''
return [(x-1, y), (x-1, y-1), (x, y-1), (x+1, y-1), (x+1, y), (x+1, y+1), (x, y+1), (x-1, y+1)]
def build_graph(layout_str, slanted):
'''
builds an adjacency graph as a dictionary: {character: [adjacent_characters]}.
adjacent characters occur in a clockwise order.
for example:
* on qwerty layout, 'g' maps to ['fF', 'tT', 'yY', 'hH', 'bB', 'vV']
* on keypad layout, '7' maps to [None, None, None, '=', '8', '5', '4', None]
'''
position_table = {} # maps from tuple (x,y) -> characters at that position.
tokens = layout_str.split()
token_size = len(tokens[0])
x_unit = token_size + 1 # x position unit len is token len plus 1 for the following whitespace.
adjacency_func = get_slanted_adjacent_coords if slanted else get_aligned_adjacent_coords
assert all(len(token) == token_size for token in tokens), 'token len mismatch:\n ' + layout_str
for y, line in enumerate(layout_str.split('\n')):
# the way I illustrated keys above, each qwerty row is indented one space in from the last
slant = y - 1 if slanted else 0
for token in line.split():
x, remainder = divmod(line.index(token) - slant, x_unit)
assert remainder == 0, 'unexpected x offset for %s in:\n%s' % (token, layout_str)
position_table[(x,y)] = token
adjacency_graph = {}
for (x,y), chars in position_table.iteritems():
for char in chars:
adjacency_graph[char] = []
for coord in adjacency_func(x, y):
# position in the list indicates direction
# (for qwerty, 0 is left, 1 is top, 2 is top right, ...)
# for edge chars like 1 or m, insert None as a placeholder when needed
# so that each character in the graph has a same-length adjacency list.
adjacency_graph[char].append(position_table.get(coord, None))
return adjacency_graph
if __name__ == '__main__':
if len(sys.argv) != 2:
print usage()
sys.exit(0)
with open(sys.argv[1], 'w') as f:
data = {
'qwerty': build_graph(qwerty, True),
'dvorak': build_graph(dvorak, True),
'keypad': build_graph(keypad, False),
'mac_keypad': build_graph(mac_keypad, False),
}
simplejson.dump(data, f)
sys.exit(0)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
<?xml version="1.0"?>
<ruleset>
<rule ref="PSR12">
<exclude name="Generic.Files.LineLength.TooLong" />
</rule>
<arg name="ignore" value="vendor/"/>
<arg name="ignore" value="build/"/>
</ruleset>

View File

@@ -1,5 +0,0 @@
parameters:
level: 0
paths:
- src
- test

View File

@@ -1,57 +0,0 @@
<?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;
}
}

View File

@@ -1,115 +0,0 @@
<?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)
);
}
}

View File

@@ -1,151 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
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{'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());
}
}

View File

@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
use ZxcvbnPhp\Scorer;
final 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];
}
/**
* @return array{'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);
}
}

View File

@@ -1,430 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
use ZxcvbnPhp\Matcher;
/** @phpstan-consistent-constructor */
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;
}
/**
* @return array{'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;
}
}

View File

@@ -1,236 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
use ZxcvbnPhp\Matcher;
use ZxcvbnPhp\Math\Binomial;
/** @phpstan-consistent-constructor */
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;
}
}
/**
* @return array{'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;
}
}

View File

@@ -1,244 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
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;
}
}
/**
* @return array{'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;
}
}

View File

@@ -1,24 +0,0 @@
<?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;
}

View File

@@ -1,127 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
use ZxcvbnPhp\Matcher;
use ZxcvbnPhp\Scorer;
/** @phpstan-consistent-constructor */
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;
}
/**
* @return array{'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;
}
}

View File

@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
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;
}
/**
* @return array{'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;
}
}

View File

@@ -1,142 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
/** @phpstan-consistent-constructor */
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,
]);
}
}
}
/**
* @return array{'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);
}
}

View File

@@ -1,265 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
use ZxcvbnPhp\Matcher;
use ZxcvbnPhp\Math\Binomial;
/** @phpstan-consistent-constructor */
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;
}
/**
* @return array{'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;
}
}

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace ZxcvbnPhp\Matchers;
use ZxcvbnPhp\Matcher;
final 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|20\d\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;
}
/**
* @return array{'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

View File

@@ -1,70 +0,0 @@
<?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();
}
}

View File

@@ -1,17 +0,0 @@
<?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;
}

View File

@@ -1,28 +0,0 @@
<?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;
}

View File

@@ -1,31 +0,0 @@
<?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;
}
}

View File

@@ -1,23 +0,0 @@
<?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;
}
}

View File

@@ -1,33 +0,0 @@
<?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;
}
}
}

View File

@@ -1,17 +0,0 @@
<?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));
}
}

View File

@@ -1,274 +0,0 @@
<?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): float
{
if ($n < 2) {
return 1;
}
$f = 1;
for ($i = 2; $i <= $n; $i++) {
$f *= $i;
}
return $f;
}
}

View File

@@ -1,124 +0,0 @@
<?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;
}
}

View File

@@ -1,90 +0,0 @@
<?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
]
);
}
}