zxcvbn-ruby 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +18 -0
- data/.rspec +1 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +23 -0
- data/Rakefile +18 -0
- data/data/adjacency_graphs.json +9 -0
- data/data/frequency_lists.yaml +85094 -0
- data/lib/zxcvbn.rb +37 -0
- data/lib/zxcvbn/crack_time.rb +51 -0
- data/lib/zxcvbn/dictionary_ranker.rb +23 -0
- data/lib/zxcvbn/entropy.rb +151 -0
- data/lib/zxcvbn/match.rb +13 -0
- data/lib/zxcvbn/matchers/date.rb +134 -0
- data/lib/zxcvbn/matchers/dictionary.rb +34 -0
- data/lib/zxcvbn/matchers/digits.rb +18 -0
- data/lib/zxcvbn/matchers/l33t.rb +127 -0
- data/lib/zxcvbn/matchers/new_l33t.rb +120 -0
- data/lib/zxcvbn/matchers/regex_helpers.rb +21 -0
- data/lib/zxcvbn/matchers/repeat.rb +32 -0
- data/lib/zxcvbn/matchers/sequences.rb +64 -0
- data/lib/zxcvbn/matchers/spatial.rb +79 -0
- data/lib/zxcvbn/matchers/year.rb +18 -0
- data/lib/zxcvbn/math.rb +63 -0
- data/lib/zxcvbn/omnimatch.rb +49 -0
- data/lib/zxcvbn/password_strength.rb +21 -0
- data/lib/zxcvbn/score.rb +15 -0
- data/lib/zxcvbn/scorer.rb +84 -0
- data/lib/zxcvbn/version.rb +3 -0
- data/spec/matchers/date_spec.rb +109 -0
- data/spec/matchers/dictionary_spec.rb +14 -0
- data/spec/matchers/digits_spec.rb +15 -0
- data/spec/matchers/l33t_spec.rb +85 -0
- data/spec/matchers/repeat_spec.rb +18 -0
- data/spec/matchers/sequences_spec.rb +16 -0
- data/spec/matchers/spatial_spec.rb +20 -0
- data/spec/matchers/year_spec.rb +15 -0
- data/spec/omnimatch_spec.rb +24 -0
- data/spec/scorer_spec.rb +5 -0
- data/spec/scoring/crack_time_spec.rb +106 -0
- data/spec/scoring/entropy_spec.rb +213 -0
- data/spec/scoring/math_spec.rb +131 -0
- data/spec/spec_helper.rb +54 -0
- data/spec/support/js_helpers.rb +35 -0
- data/spec/support/js_source/adjacency_graphs.js +8 -0
- data/spec/support/js_source/compiled.js +1188 -0
- data/spec/support/js_source/frequency_lists.js +10 -0
- data/spec/support/js_source/init.coffee +63 -0
- data/spec/support/js_source/init.js +95 -0
- data/spec/support/js_source/matching.coffee +444 -0
- data/spec/support/js_source/matching.js +685 -0
- data/spec/support/js_source/scoring.coffee +270 -0
- data/spec/support/js_source/scoring.js +390 -0
- data/spec/support/matcher.rb +35 -0
- data/spec/zxcvbn_spec.rb +49 -0
- data/zxcvbn-ruby.gemspec +20 -0
- metadata +167 -0
data/lib/zxcvbn.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'zxcvbn/version'
|
2
|
+
require 'zxcvbn/match'
|
3
|
+
require 'zxcvbn/matchers/regex_helpers'
|
4
|
+
require 'zxcvbn/matchers/dictionary'
|
5
|
+
require 'zxcvbn/matchers/l33t'
|
6
|
+
require 'zxcvbn/matchers/spatial'
|
7
|
+
require 'zxcvbn/matchers/sequences'
|
8
|
+
require 'zxcvbn/matchers/repeat'
|
9
|
+
require 'zxcvbn/matchers/digits'
|
10
|
+
require 'zxcvbn/matchers/year'
|
11
|
+
require 'zxcvbn/matchers/date'
|
12
|
+
require 'zxcvbn/dictionary_ranker'
|
13
|
+
require 'zxcvbn/omnimatch'
|
14
|
+
require 'zxcvbn/math'
|
15
|
+
require 'zxcvbn/entropy'
|
16
|
+
require 'zxcvbn/crack_time'
|
17
|
+
require 'zxcvbn/score'
|
18
|
+
require 'zxcvbn/scorer'
|
19
|
+
require 'zxcvbn/password_strength'
|
20
|
+
|
21
|
+
module Zxcvbn
|
22
|
+
extend self
|
23
|
+
|
24
|
+
DATA_PATH = Pathname(File.expand_path('../../data', __FILE__))
|
25
|
+
ADJACENCY_GRAPHS = JSON.load(DATA_PATH.join('adjacency_graphs.json').read)
|
26
|
+
FREQUENCY_LISTS = YAML.load(DATA_PATH.join('frequency_lists.yaml').read)
|
27
|
+
RANKED_DICTIONARIES = DictionaryRanker.rank_dictionaries(FREQUENCY_LISTS)
|
28
|
+
|
29
|
+
def test(password, user_inputs = [])
|
30
|
+
zxcvbn = PasswordStrength.new
|
31
|
+
zxcvbn.test(password, user_inputs)
|
32
|
+
end
|
33
|
+
|
34
|
+
def add_word_list(name, list)
|
35
|
+
RANKED_DICTIONARIES[name] = DictionaryRanker.rank_dictionary(list)
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Zxcvbn::CrackTime
|
2
|
+
SINGLE_GUESS = 0.010
|
3
|
+
NUM_ATTACKERS = 100
|
4
|
+
|
5
|
+
SECONDS_PER_GUESS = SINGLE_GUESS / NUM_ATTACKERS
|
6
|
+
|
7
|
+
def entropy_to_crack_time(entropy)
|
8
|
+
0.5 * (2 ** entropy) * SECONDS_PER_GUESS
|
9
|
+
end
|
10
|
+
|
11
|
+
def crack_time_to_score(seconds)
|
12
|
+
case
|
13
|
+
when seconds < 10**2
|
14
|
+
0
|
15
|
+
when seconds < 10**4
|
16
|
+
1
|
17
|
+
when seconds < 10**6
|
18
|
+
2
|
19
|
+
when seconds < 10**8
|
20
|
+
3
|
21
|
+
else
|
22
|
+
4
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def display_time(seconds)
|
27
|
+
minute = 60
|
28
|
+
hour = minute * 60
|
29
|
+
day = hour * 24
|
30
|
+
month = day * 31
|
31
|
+
year = month * 12
|
32
|
+
century = year * 100
|
33
|
+
|
34
|
+
case
|
35
|
+
when seconds < minute
|
36
|
+
'instant'
|
37
|
+
when seconds < hour
|
38
|
+
"#{1 + (seconds / minute).ceil} minutes"
|
39
|
+
when seconds < day
|
40
|
+
"#{1 + (seconds / hour).ceil} hours"
|
41
|
+
when seconds < month
|
42
|
+
"#{1 + (seconds / day).ceil} days"
|
43
|
+
when seconds < year
|
44
|
+
"#{1 + (seconds / month).ceil} months"
|
45
|
+
when seconds < century
|
46
|
+
"#{1 + (seconds / year).ceil} years"
|
47
|
+
else
|
48
|
+
'centuries'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Zxcvbn
|
4
|
+
class DictionaryRanker
|
5
|
+
def self.rank_dictionaries(lists)
|
6
|
+
dictionaries = {}
|
7
|
+
lists.each do |dict_name, words|
|
8
|
+
dictionaries[dict_name] = rank_dictionary(words)
|
9
|
+
end
|
10
|
+
dictionaries
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.rank_dictionary(words)
|
14
|
+
dictionary = {}
|
15
|
+
i = 1
|
16
|
+
words.each do |word|
|
17
|
+
dictionary[word] = i
|
18
|
+
i += 1
|
19
|
+
end
|
20
|
+
dictionary
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module Zxcvbn::Entropy
|
2
|
+
include Zxcvbn::Math
|
3
|
+
|
4
|
+
def calc_entropy(match)
|
5
|
+
return match.entropy unless match.entropy.nil?
|
6
|
+
# debugger
|
7
|
+
match.entropy = case match.pattern
|
8
|
+
when 'repeat'
|
9
|
+
repeat_entropy(match)
|
10
|
+
when 'sequence'
|
11
|
+
sequence_entropy(match)
|
12
|
+
when 'digits'
|
13
|
+
digits_entropy(match)
|
14
|
+
when 'year'
|
15
|
+
year_entropy(match)
|
16
|
+
when 'date'
|
17
|
+
date_entropy(match)
|
18
|
+
when 'spatial'
|
19
|
+
spatial_entropy(match)
|
20
|
+
when 'dictionary'
|
21
|
+
dictionary_entropy(match)
|
22
|
+
end
|
23
|
+
match.entropy ||= 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def repeat_entropy(match)
|
27
|
+
cardinality = bruteforce_cardinality match.token
|
28
|
+
lg(cardinality * match.token.length)
|
29
|
+
end
|
30
|
+
|
31
|
+
def sequence_entropy(match)
|
32
|
+
first_char = match.token[0]
|
33
|
+
base_entropy = if ['a', '1'].include?(first_char)
|
34
|
+
1
|
35
|
+
elsif first_char.match(/\d/)
|
36
|
+
lg(10)
|
37
|
+
elsif first_char.match(/[a-z]/)
|
38
|
+
lg(26)
|
39
|
+
else
|
40
|
+
lg(26) + 1
|
41
|
+
end
|
42
|
+
base_entropy += 1 unless match.ascending
|
43
|
+
base_entropy + lg(match.token.length)
|
44
|
+
end
|
45
|
+
|
46
|
+
def digits_entropy(match)
|
47
|
+
lg(10 ** match.token.length)
|
48
|
+
end
|
49
|
+
|
50
|
+
NUM_YEARS = 119 # years match against 1900 - 2019
|
51
|
+
NUM_MONTHS = 12
|
52
|
+
NUM_DAYS = 31
|
53
|
+
|
54
|
+
def year_entropy(match)
|
55
|
+
lg(NUM_YEARS)
|
56
|
+
end
|
57
|
+
|
58
|
+
def date_entropy(match)
|
59
|
+
if match.year < 100
|
60
|
+
entropy = lg(NUM_DAYS * NUM_MONTHS * 100)
|
61
|
+
else
|
62
|
+
entropy = lg(NUM_DAYS * NUM_MONTHS * NUM_YEARS)
|
63
|
+
end
|
64
|
+
|
65
|
+
if match.separator
|
66
|
+
entropy += 2
|
67
|
+
end
|
68
|
+
|
69
|
+
entropy
|
70
|
+
end
|
71
|
+
|
72
|
+
def dictionary_entropy(match)
|
73
|
+
match.base_entropy = lg(match.rank)
|
74
|
+
match.uppercase_entropy = extra_uppercase_entropy(match)
|
75
|
+
match.l33t_entropy = extra_l33t_entropy(match)
|
76
|
+
|
77
|
+
match.base_entropy + match.uppercase_entropy + match.l33t_entropy
|
78
|
+
end
|
79
|
+
|
80
|
+
START_UPPER = /^[A-Z][^A-Z]+$/
|
81
|
+
END_UPPER = /^[^A-Z]+[A-Z]$/
|
82
|
+
ALL_UPPER = /^[A-Z]+$/
|
83
|
+
ALL_LOWER = /^[a-z]+$/
|
84
|
+
|
85
|
+
def extra_uppercase_entropy(match)
|
86
|
+
word = match.token
|
87
|
+
[START_UPPER, END_UPPER, ALL_UPPER].each do |regex|
|
88
|
+
return 1 if word.match(regex)
|
89
|
+
end
|
90
|
+
num_upper = word.chars.count{|c| c.match(/[A-Z]/) }
|
91
|
+
num_lower = word.chars.count{|c| c.match(/[a-z]/) }
|
92
|
+
possibilities = 0
|
93
|
+
(0..min(num_upper, num_lower)).each do |i|
|
94
|
+
possibilities += nCk(num_upper + num_lower, i)
|
95
|
+
end
|
96
|
+
lg(possibilities)
|
97
|
+
end
|
98
|
+
|
99
|
+
def extra_l33t_entropy(match)
|
100
|
+
word = match.token
|
101
|
+
return 0 unless match.l33t
|
102
|
+
possibilities = 0
|
103
|
+
match.sub.each do |subbed, unsubbed|
|
104
|
+
num_subbed = word.chars.count{|c| c == subbed}
|
105
|
+
num_unsubbed = word.chars.count{|c| c == unsubbed}
|
106
|
+
(0..min(num_subbed, num_unsubbed)).each do |i|
|
107
|
+
possibilities += nCk(num_subbed + num_unsubbed, i)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
entropy = lg(possibilities)
|
111
|
+
entropy == 0 ? 1 : entropy
|
112
|
+
end
|
113
|
+
|
114
|
+
def spatial_entropy(match)
|
115
|
+
if %w|qwerty dvorak|.include? match.graph
|
116
|
+
starting_positions = starting_positions_for_graph('qwerty')
|
117
|
+
average_degree = average_degree_for_graph('qwerty')
|
118
|
+
else
|
119
|
+
starting_positions = starting_positions_for_graph('keypad')
|
120
|
+
average_degree = average_degree_for_graph('keypad')
|
121
|
+
end
|
122
|
+
|
123
|
+
possibilities = 0
|
124
|
+
token_length = match.token.length
|
125
|
+
turns = match.turns
|
126
|
+
|
127
|
+
# estimate the ngpumber of possible patterns w/ token length or less with number of turns or less.
|
128
|
+
(2..token_length).each do |i|
|
129
|
+
possible_turns = [turns, i -1].min
|
130
|
+
(1..possible_turns).each do |j|
|
131
|
+
possibilities += nCk(i - 1, j - 1) * starting_positions * average_degree ** j
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
entropy = lg possibilities
|
136
|
+
# add extra entropy for shifted keys. (% instead of 5, A instead of a.)
|
137
|
+
# math is similar to extra entropy from uppercase letters in dictionary matches.
|
138
|
+
|
139
|
+
if match.shifted_count
|
140
|
+
shiffted_count = match.shifted_count
|
141
|
+
unshifted_count = match.token.length - match.shifted_count
|
142
|
+
possibilities = 0
|
143
|
+
|
144
|
+
(0..[shiffted_count, unshifted_count].min).each do |i|
|
145
|
+
possibilities += nCk(shiffted_count + unshifted_count, i)
|
146
|
+
end
|
147
|
+
entropy += lg possibilities
|
148
|
+
end
|
149
|
+
entropy
|
150
|
+
end
|
151
|
+
end
|
data/lib/zxcvbn/match.rb
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class Date
|
4
|
+
include RegexHelpers
|
5
|
+
|
6
|
+
YEAR_SUFFIX = /
|
7
|
+
( \d{1,2} ) # day or month
|
8
|
+
( \s | \- | \/ | \\ | \_ | \. ) # separator
|
9
|
+
( \d{1,2} ) # month or day
|
10
|
+
\2 # same separator
|
11
|
+
( 19\d{2} | 200\d | 201\d | \d{2} ) # year
|
12
|
+
/x
|
13
|
+
|
14
|
+
YEAR_PREFIX = /
|
15
|
+
( 19\d{2} | 200\d | 201\d | \d{2} ) # year
|
16
|
+
( \s | - | \/ | \\ | _ | \. ) # separator
|
17
|
+
( \d{1,2} ) # day or month
|
18
|
+
\2 # same separator
|
19
|
+
( \d{1,2} ) # month or day
|
20
|
+
/x
|
21
|
+
|
22
|
+
WITHOUT_SEPARATOR = /\d{4,8}/
|
23
|
+
|
24
|
+
def matches(password)
|
25
|
+
result = []
|
26
|
+
result += match_with_separator(password)
|
27
|
+
result += match_without_separator(password)
|
28
|
+
result
|
29
|
+
end
|
30
|
+
|
31
|
+
def match_with_separator(password)
|
32
|
+
result = []
|
33
|
+
re_match_all(YEAR_SUFFIX, password) do |match, re_match|
|
34
|
+
match.pattern = 'date'
|
35
|
+
match.day = re_match[1].to_i
|
36
|
+
match.separator = re_match[2]
|
37
|
+
match.month = re_match[3].to_i
|
38
|
+
match.year = re_match[4].to_i
|
39
|
+
|
40
|
+
day, month = match.day, match.month
|
41
|
+
if month > 12
|
42
|
+
match.day = month
|
43
|
+
match.month = day
|
44
|
+
end
|
45
|
+
|
46
|
+
result << match if valid_date?(match.day, match.month, match.year)
|
47
|
+
end
|
48
|
+
result
|
49
|
+
end
|
50
|
+
|
51
|
+
def match_without_separator(password)
|
52
|
+
result = []
|
53
|
+
re_match_all(WITHOUT_SEPARATOR, password) do |match, re_match|
|
54
|
+
extract_dates(match.token).each do |candidate|
|
55
|
+
day, month, year = candidate[:day], candidate[:month], candidate[:year]
|
56
|
+
|
57
|
+
match = match.dup
|
58
|
+
match.pattern = 'date'
|
59
|
+
match.day = day
|
60
|
+
match.month = month
|
61
|
+
match.year = year
|
62
|
+
match.separator = ''
|
63
|
+
result << match
|
64
|
+
end
|
65
|
+
end
|
66
|
+
result
|
67
|
+
end
|
68
|
+
|
69
|
+
def extract_dates(token)
|
70
|
+
dates = []
|
71
|
+
date_patterns_for_length(token.length).map do |pattern|
|
72
|
+
candidate = {
|
73
|
+
:year => '',
|
74
|
+
:month => '',
|
75
|
+
:day => ''
|
76
|
+
}
|
77
|
+
for i in 0...token.length
|
78
|
+
candidate[PATTERN_CHAR_TO_SYM[pattern[i]]] << token[i]
|
79
|
+
end
|
80
|
+
candidate.each do |component, value|
|
81
|
+
candidate[component] = value.to_i
|
82
|
+
end
|
83
|
+
|
84
|
+
candidate[:year] = expand_year(candidate[:year])
|
85
|
+
|
86
|
+
if valid_date?(candidate[:day], candidate[:month], candidate[:year]) && !matches_year?(token)
|
87
|
+
dates << candidate
|
88
|
+
end
|
89
|
+
end
|
90
|
+
dates
|
91
|
+
end
|
92
|
+
|
93
|
+
DATE_PATTERN_FOR_LENGTH = {
|
94
|
+
8 => %w[ yyyymmdd ddmmyyyy mmddyyyy ],
|
95
|
+
7 => %w[ yyyymdd yyyymmd ddmyyyy dmmyyyy ],
|
96
|
+
6 => %w[ yymmdd ddmmyy mmddyy ],
|
97
|
+
5 => %w[ yymdd yymmd ddmyy dmmyy mmdyy mddyy ],
|
98
|
+
4 => %w[ yymd dmyy mdyy ]
|
99
|
+
}
|
100
|
+
|
101
|
+
PATTERN_CHAR_TO_SYM = {
|
102
|
+
'y' => :year,
|
103
|
+
'm' => :month,
|
104
|
+
'd' => :day
|
105
|
+
}
|
106
|
+
|
107
|
+
def date_patterns_for_length(length)
|
108
|
+
DATE_PATTERN_FOR_LENGTH[length] || []
|
109
|
+
end
|
110
|
+
|
111
|
+
def valid_date?(day, month, year)
|
112
|
+
return false if day > 31 || month > 12
|
113
|
+
return false unless year >= 1900 && year <= 2019
|
114
|
+
true
|
115
|
+
end
|
116
|
+
|
117
|
+
def matches_year?(token)
|
118
|
+
token.size == 4 && Year::YEAR_REGEX.match(token)
|
119
|
+
end
|
120
|
+
|
121
|
+
def expand_year(year)
|
122
|
+
return year
|
123
|
+
# Block dates with 2 digit years for now to be compatible with the JS version
|
124
|
+
# return year unless year < 100
|
125
|
+
# now = Time.now.year
|
126
|
+
# if year <= 19
|
127
|
+
# year + 2000
|
128
|
+
# else
|
129
|
+
# year + 1900
|
130
|
+
# end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
# Given a password and a dictionary, match on any sequential segment of
|
4
|
+
# the lowercased password in the dictionary
|
5
|
+
|
6
|
+
class Dictionary
|
7
|
+
def initialize(name, ranked_dictionary)
|
8
|
+
@name = name
|
9
|
+
@ranked_dictionary = ranked_dictionary
|
10
|
+
end
|
11
|
+
|
12
|
+
def matches(password)
|
13
|
+
results = []
|
14
|
+
password_length = password.length
|
15
|
+
lowercased_password = password.downcase
|
16
|
+
(0..password_length).each do |i|
|
17
|
+
(i...password_length).each do |j|
|
18
|
+
word = lowercased_password[i..j]
|
19
|
+
if @ranked_dictionary.has_key?(word)
|
20
|
+
results << Match.new(:matched_word => word,
|
21
|
+
:token => password[i..j],
|
22
|
+
:i => i,
|
23
|
+
:j => j,
|
24
|
+
:rank => @ranked_dictionary[word],
|
25
|
+
:pattern => 'dictionary',
|
26
|
+
:dictionary_name => @name)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
results
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|