zxcvbn-ruby 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|