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
@@ -0,0 +1,18 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class Digits
|
4
|
+
include RegexHelpers
|
5
|
+
|
6
|
+
DIGITS_REGEX = /\d{3,}/
|
7
|
+
|
8
|
+
def matches(password)
|
9
|
+
result = []
|
10
|
+
re_match_all(DIGITS_REGEX, password) do |match|
|
11
|
+
match.pattern = 'digits'
|
12
|
+
result << match
|
13
|
+
end
|
14
|
+
result
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class L33t
|
4
|
+
L33T_TABLE = {
|
5
|
+
'a' => ['4', '@'],
|
6
|
+
'b' => ['8'],
|
7
|
+
'c' => ['(', '{', '[', '<'],
|
8
|
+
'e' => ['3'],
|
9
|
+
'g' => ['6', '9'],
|
10
|
+
'i' => ['1', '!', '|'],
|
11
|
+
'l' => ['1', '|', '7'],
|
12
|
+
'o' => ['0'],
|
13
|
+
's' => ['$', '5'],
|
14
|
+
't' => ['+', '7'],
|
15
|
+
'x' => ['%'],
|
16
|
+
'z' => ['2']
|
17
|
+
}
|
18
|
+
|
19
|
+
def initialize(dictionary_matchers)
|
20
|
+
@dictionary_matchers = dictionary_matchers
|
21
|
+
end
|
22
|
+
|
23
|
+
def matches(password)
|
24
|
+
matches = []
|
25
|
+
lowercased_password = password.downcase
|
26
|
+
combinations_to_try = l33t_subs(relevent_l33t_subtable(lowercased_password))
|
27
|
+
combinations_to_try.each do |substitution|
|
28
|
+
@dictionary_matchers.each do |matcher|
|
29
|
+
subbed_password = translate(lowercased_password, substitution)
|
30
|
+
matcher.matches(subbed_password).each do |match|
|
31
|
+
token = password[match.i..match.j]
|
32
|
+
next if token.downcase == match.matched_word.downcase
|
33
|
+
match_substitutions = {}
|
34
|
+
substitution.each do |substitution, letter|
|
35
|
+
match_substitutions[substitution] = letter if token.include?(substitution)
|
36
|
+
end
|
37
|
+
match.l33t = true
|
38
|
+
match.token = password[match.i..match.j]
|
39
|
+
match.sub = match_substitutions
|
40
|
+
match.sub_display = match_substitutions.map do |k, v|
|
41
|
+
"#{k} -> #{v}"
|
42
|
+
end.join(', ')
|
43
|
+
matches << match
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
matches
|
48
|
+
end
|
49
|
+
|
50
|
+
def translate(password, sub)
|
51
|
+
password.split('').map do |chr|
|
52
|
+
sub[chr] || chr
|
53
|
+
end.join
|
54
|
+
end
|
55
|
+
|
56
|
+
def relevent_l33t_subtable(password)
|
57
|
+
filtered = {}
|
58
|
+
L33T_TABLE.each do |letter, subs|
|
59
|
+
relevent_subs = subs.select { |s| password.include?(s) }
|
60
|
+
filtered[letter] = relevent_subs unless relevent_subs.empty?
|
61
|
+
end
|
62
|
+
filtered
|
63
|
+
end
|
64
|
+
|
65
|
+
def l33t_subs(table)
|
66
|
+
keys = table.keys
|
67
|
+
subs = [[]]
|
68
|
+
subs = find_substitutions(subs, table, keys)
|
69
|
+
new_subs = []
|
70
|
+
subs.each do |sub|
|
71
|
+
hash = {}
|
72
|
+
sub.each do |l33t_char, chr|
|
73
|
+
hash[l33t_char] = chr
|
74
|
+
end
|
75
|
+
new_subs << hash
|
76
|
+
end
|
77
|
+
new_subs
|
78
|
+
end
|
79
|
+
|
80
|
+
def find_substitutions(subs, table, keys)
|
81
|
+
return subs if keys.empty?
|
82
|
+
first_key = keys[0]
|
83
|
+
rest_keys = keys[1..-1]
|
84
|
+
next_subs = []
|
85
|
+
table[first_key].each do |l33t_char|
|
86
|
+
subs.each do |sub|
|
87
|
+
dup_l33t_index = -1
|
88
|
+
(0...sub.length).each do |i|
|
89
|
+
if sub[i][0] == l33t_char
|
90
|
+
dup_l33t_index = i
|
91
|
+
break
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
if dup_l33t_index == -1
|
96
|
+
sub_extension = sub + [[l33t_char, first_key]]
|
97
|
+
next_subs << sub_extension
|
98
|
+
else
|
99
|
+
sub_alternative = sub.dup
|
100
|
+
sub_alternative[dup_l33t_index, 1] = [[l33t_char, first_key]]
|
101
|
+
next_subs << sub
|
102
|
+
next_subs << sub_alternative
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
subs = dedup(next_subs)
|
107
|
+
find_substitutions(subs, table, rest_keys)
|
108
|
+
end
|
109
|
+
|
110
|
+
def dedup(subs)
|
111
|
+
deduped = []
|
112
|
+
members = []
|
113
|
+
subs.each do |sub|
|
114
|
+
assoc = sub.dup
|
115
|
+
|
116
|
+
assoc.sort! rescue debugger
|
117
|
+
label = assoc.map{|k, v| "#{k},#{v}"}.join('-')
|
118
|
+
unless members.include?(label)
|
119
|
+
members << label
|
120
|
+
deduped << sub
|
121
|
+
end
|
122
|
+
end
|
123
|
+
deduped
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class L33t
|
4
|
+
L33T_TABLE = {
|
5
|
+
'a' => ['4', '@'],
|
6
|
+
'b' => ['8'],
|
7
|
+
'c' => ['(', '{', '[', '<'],
|
8
|
+
'e' => ['3'],
|
9
|
+
'g' => ['6', '9'],
|
10
|
+
'i' => ['1', '!', '|'],
|
11
|
+
'l' => ['1', '|', '7'],
|
12
|
+
'o' => ['0'],
|
13
|
+
's' => ['$', '5'],
|
14
|
+
't' => ['+', '7'],
|
15
|
+
'x' => ['%'],
|
16
|
+
'z' => ['2']
|
17
|
+
}
|
18
|
+
|
19
|
+
def initialize(dictionary_matchers)
|
20
|
+
@dictionary_matchers = dictionary_matchers
|
21
|
+
end
|
22
|
+
|
23
|
+
def matches(password)
|
24
|
+
matches = []
|
25
|
+
lowercased_password = password.downcase
|
26
|
+
combinations_to_try = substitution_combinations(relevant_l33t_substitutions(lowercased_password))
|
27
|
+
# debugger if password == 'abcdefghijk987654321'
|
28
|
+
combinations_to_try.each do |substitution|
|
29
|
+
@dictionary_matchers.each do |matcher|
|
30
|
+
subbed_password = substitute(lowercased_password, substitution)
|
31
|
+
matcher.matches(subbed_password).each do |match|
|
32
|
+
token = lowercased_password[match.i..match.j]
|
33
|
+
next if token == match.matched_word.downcase
|
34
|
+
# debugger if token == '1'
|
35
|
+
match_substitutions = {}
|
36
|
+
substitution.each do |letter, substitution|
|
37
|
+
match_substitutions[substitution] = letter if token.include?(substitution)
|
38
|
+
end
|
39
|
+
match.l33t = true
|
40
|
+
match.token = password[match.i..match.j]
|
41
|
+
match.sub = match_substitutions
|
42
|
+
match.sub_display = match_substitutions.map do |k, v|
|
43
|
+
"#{k} -> #{v}"
|
44
|
+
end.join(', ')
|
45
|
+
matches << match
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
matches
|
50
|
+
end
|
51
|
+
|
52
|
+
def substitute(password, substitution)
|
53
|
+
subbed_password = password.dup
|
54
|
+
substitution.each do |letter, substitution|
|
55
|
+
subbed_password.gsub!(substitution, letter)
|
56
|
+
end
|
57
|
+
subbed_password
|
58
|
+
end
|
59
|
+
|
60
|
+
# produces a l33t table of substitutions present in the given password
|
61
|
+
def relevant_l33t_substitutions(password)
|
62
|
+
subs = Hash.new do |hash, key|
|
63
|
+
hash[key] = []
|
64
|
+
end
|
65
|
+
L33T_TABLE.each do |letter, substibutions|
|
66
|
+
password.each_char do |password_char|
|
67
|
+
if substibutions.include?(password_char)
|
68
|
+
subs[letter] << password_char
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
subs
|
73
|
+
end
|
74
|
+
|
75
|
+
# takes a character substitutions hash and produces an array of all
|
76
|
+
# possible substitution combinations
|
77
|
+
def substitution_combinations(subs_hash)
|
78
|
+
combinations = []
|
79
|
+
expanded_substitutions = expanded_substitutions(subs_hash)
|
80
|
+
|
81
|
+
# build an array of all possible combinations
|
82
|
+
expanded_substitutions.each do |substitution_hash|
|
83
|
+
# convert a hash to an array of hashes with 1 key each
|
84
|
+
subs_array = substitution_hash.map do |letter, substitutions|
|
85
|
+
{letter => substitutions}
|
86
|
+
end
|
87
|
+
combinations << subs_array
|
88
|
+
|
89
|
+
# find all possible combinations for each number of combinations available
|
90
|
+
subs_array.combination(subs_array.size).each do |combination|
|
91
|
+
# Don't add duplicates
|
92
|
+
combinations << combination unless combinations.include?(combination)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# convert back to simple hash per substitution combination
|
97
|
+
combination_hashes = combinations.map do |combination_set|
|
98
|
+
hash = {}
|
99
|
+
combination_set.each do |combination_hash|
|
100
|
+
hash.merge!(combination_hash)
|
101
|
+
end
|
102
|
+
hash
|
103
|
+
end
|
104
|
+
|
105
|
+
combination_hashes
|
106
|
+
end
|
107
|
+
|
108
|
+
# expand possible combinations if multiple characters can be substituted
|
109
|
+
# e.g. {'a' => ['4', '@'], 'i' => ['1']} expands to
|
110
|
+
# [{'a' => '4', 'i' => 1}, {'a' => '@', 'i' => '1'}]
|
111
|
+
def expanded_substitutions(hash)
|
112
|
+
return {} if hash.empty?
|
113
|
+
values = hash.values
|
114
|
+
product_values = values[0].product(*values[1..-1])
|
115
|
+
product_values.map{ |p| Hash[hash.keys.zip(p)] }
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
module RegexHelpers
|
4
|
+
def re_match_all(regex, password)
|
5
|
+
loop do
|
6
|
+
re_match = regex.match(password)
|
7
|
+
break unless re_match
|
8
|
+
i, j = re_match.offset(0)
|
9
|
+
j -= 1
|
10
|
+
match = Match.new(
|
11
|
+
:i => i,
|
12
|
+
:j => j,
|
13
|
+
:token => password[i..j]
|
14
|
+
)
|
15
|
+
yield match, re_match
|
16
|
+
password = password.sub(re_match[0], ' ' * re_match[0].length)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class Repeat
|
4
|
+
def matches(password)
|
5
|
+
result = []
|
6
|
+
i = 0
|
7
|
+
while i < password.length
|
8
|
+
j = i + 1
|
9
|
+
loop do
|
10
|
+
prev_char, cur_char = password[j-1..j]
|
11
|
+
if password[j-1] == password[j]
|
12
|
+
j += 1
|
13
|
+
else
|
14
|
+
if j - i > 2 # don't consider length 1 or 2 chains.
|
15
|
+
result << Match.new(
|
16
|
+
:pattern => 'repeat',
|
17
|
+
:i => i,
|
18
|
+
:j => j-1,
|
19
|
+
:token => password[i...j],
|
20
|
+
:repeated_char => password[i]
|
21
|
+
)
|
22
|
+
end
|
23
|
+
break
|
24
|
+
end
|
25
|
+
end
|
26
|
+
i = j
|
27
|
+
end
|
28
|
+
result
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class Sequences
|
4
|
+
SEQUENCES = {
|
5
|
+
'lower' => 'abcdefghijklmnopqrstuvwxyz',
|
6
|
+
'upper' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
7
|
+
'digits' => '01234567890'
|
8
|
+
}
|
9
|
+
|
10
|
+
def matches(password)
|
11
|
+
result = []
|
12
|
+
i = 0
|
13
|
+
while i < password.length-1
|
14
|
+
j = i + 1
|
15
|
+
seq = nil # either lower, upper, or digits
|
16
|
+
seq_name = nil
|
17
|
+
seq_direction = nil # 1 for ascending seq abcd, -1 for dcba
|
18
|
+
SEQUENCES.each do |seq_candidate_name, seq_candidate|
|
19
|
+
seq = nil
|
20
|
+
|
21
|
+
i_n, j_n = [password[i], password[j]].map do |chr|
|
22
|
+
chr ? seq_candidate.index(chr) : nil
|
23
|
+
end
|
24
|
+
|
25
|
+
if i_n && j_n && i_n > -1 && j_n > -1
|
26
|
+
direction = j_n - i_n
|
27
|
+
if [1, -1].include?(direction)
|
28
|
+
seq = seq_candidate
|
29
|
+
seq_name = seq_candidate_name
|
30
|
+
seq_direction = direction
|
31
|
+
end
|
32
|
+
end
|
33
|
+
if seq
|
34
|
+
loop do
|
35
|
+
prev_char, cur_char = password[(j-1)], password[j]
|
36
|
+
prev_n, cur_n = [prev_char, cur_char].map do |chr|
|
37
|
+
chr ? seq_candidate.index(chr) : nil
|
38
|
+
end
|
39
|
+
if prev_n && cur_n && cur_n - prev_n == seq_direction
|
40
|
+
j += 1
|
41
|
+
else
|
42
|
+
if j - i > 2 # don't consider length 1 or 2 chains.
|
43
|
+
result << Match.new(
|
44
|
+
:pattern => 'sequence',
|
45
|
+
:i => i,
|
46
|
+
:j => j-1,
|
47
|
+
:token => password[i...j],
|
48
|
+
:sequence_name => seq_name,
|
49
|
+
:sequence_space => seq.length,
|
50
|
+
:ascending => seq_direction == 1
|
51
|
+
)
|
52
|
+
end
|
53
|
+
break
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
i = j
|
59
|
+
end
|
60
|
+
result
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class Spatial
|
4
|
+
def initialize(graphs)
|
5
|
+
@graphs = graphs
|
6
|
+
end
|
7
|
+
|
8
|
+
def matches(password)
|
9
|
+
results = []
|
10
|
+
@graphs.each do |graph_name, graph|
|
11
|
+
results += matches_for_graph(graph, graph_name, password)
|
12
|
+
end
|
13
|
+
results
|
14
|
+
end
|
15
|
+
|
16
|
+
def matches_for_graph(graph, graph_name, password)
|
17
|
+
result = []
|
18
|
+
i = 0
|
19
|
+
while i < password.length - 1
|
20
|
+
j = i + 1
|
21
|
+
last_direction = nil
|
22
|
+
turns = 0
|
23
|
+
shifted_count = 0
|
24
|
+
loop do
|
25
|
+
prev_char = password[j-1]
|
26
|
+
found = false
|
27
|
+
found_direction = -1
|
28
|
+
cur_direction = -1
|
29
|
+
adjacents = graph[prev_char] || []
|
30
|
+
# consider growing pattern by one character if j hasn't gone over the edge.
|
31
|
+
if j < password.length
|
32
|
+
cur_char = password[j]
|
33
|
+
adjacents.each do |adj|
|
34
|
+
cur_direction += 1
|
35
|
+
if adj && adj.index(cur_char)
|
36
|
+
found = true
|
37
|
+
found_direction = cur_direction
|
38
|
+
if adj.index(cur_char) == 1
|
39
|
+
# index 1 in the adjacency means the key is shifted, 0 means unshifted: A vs a, % vs 5, etc.
|
40
|
+
# for example, 'q' is adjacent to the entry '2@'. @ is shifted w/ index 1, 2 is unshifted.
|
41
|
+
shifted_count += 1
|
42
|
+
end
|
43
|
+
if last_direction != found_direction
|
44
|
+
# adding a turn is correct even in the initial case when last_direction is null:
|
45
|
+
# every spatial pattern starts with a turn.
|
46
|
+
turns += 1
|
47
|
+
last_direction = found_direction
|
48
|
+
end
|
49
|
+
break
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
# if the current pattern continued, extend j and try to grow again
|
54
|
+
if found
|
55
|
+
j += 1
|
56
|
+
else
|
57
|
+
# otherwise push the pattern discovered so far, if any...
|
58
|
+
if j - i > 2 # don't consider length 1 or 2 chains.
|
59
|
+
result << Match.new(
|
60
|
+
:pattern => 'spatial',
|
61
|
+
:i => i,
|
62
|
+
:j => j-1,
|
63
|
+
:token => password[i...j],
|
64
|
+
:graph => graph_name,
|
65
|
+
:turns => turns,
|
66
|
+
:shifted_count => shifted_count
|
67
|
+
)
|
68
|
+
end
|
69
|
+
# ...and then start a new search for the rest of the password.
|
70
|
+
i = j
|
71
|
+
break
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
result
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|