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.
Files changed (57) hide show
  1. data/.gitignore +18 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE +22 -0
  5. data/README.md +23 -0
  6. data/Rakefile +18 -0
  7. data/data/adjacency_graphs.json +9 -0
  8. data/data/frequency_lists.yaml +85094 -0
  9. data/lib/zxcvbn.rb +37 -0
  10. data/lib/zxcvbn/crack_time.rb +51 -0
  11. data/lib/zxcvbn/dictionary_ranker.rb +23 -0
  12. data/lib/zxcvbn/entropy.rb +151 -0
  13. data/lib/zxcvbn/match.rb +13 -0
  14. data/lib/zxcvbn/matchers/date.rb +134 -0
  15. data/lib/zxcvbn/matchers/dictionary.rb +34 -0
  16. data/lib/zxcvbn/matchers/digits.rb +18 -0
  17. data/lib/zxcvbn/matchers/l33t.rb +127 -0
  18. data/lib/zxcvbn/matchers/new_l33t.rb +120 -0
  19. data/lib/zxcvbn/matchers/regex_helpers.rb +21 -0
  20. data/lib/zxcvbn/matchers/repeat.rb +32 -0
  21. data/lib/zxcvbn/matchers/sequences.rb +64 -0
  22. data/lib/zxcvbn/matchers/spatial.rb +79 -0
  23. data/lib/zxcvbn/matchers/year.rb +18 -0
  24. data/lib/zxcvbn/math.rb +63 -0
  25. data/lib/zxcvbn/omnimatch.rb +49 -0
  26. data/lib/zxcvbn/password_strength.rb +21 -0
  27. data/lib/zxcvbn/score.rb +15 -0
  28. data/lib/zxcvbn/scorer.rb +84 -0
  29. data/lib/zxcvbn/version.rb +3 -0
  30. data/spec/matchers/date_spec.rb +109 -0
  31. data/spec/matchers/dictionary_spec.rb +14 -0
  32. data/spec/matchers/digits_spec.rb +15 -0
  33. data/spec/matchers/l33t_spec.rb +85 -0
  34. data/spec/matchers/repeat_spec.rb +18 -0
  35. data/spec/matchers/sequences_spec.rb +16 -0
  36. data/spec/matchers/spatial_spec.rb +20 -0
  37. data/spec/matchers/year_spec.rb +15 -0
  38. data/spec/omnimatch_spec.rb +24 -0
  39. data/spec/scorer_spec.rb +5 -0
  40. data/spec/scoring/crack_time_spec.rb +106 -0
  41. data/spec/scoring/entropy_spec.rb +213 -0
  42. data/spec/scoring/math_spec.rb +131 -0
  43. data/spec/spec_helper.rb +54 -0
  44. data/spec/support/js_helpers.rb +35 -0
  45. data/spec/support/js_source/adjacency_graphs.js +8 -0
  46. data/spec/support/js_source/compiled.js +1188 -0
  47. data/spec/support/js_source/frequency_lists.js +10 -0
  48. data/spec/support/js_source/init.coffee +63 -0
  49. data/spec/support/js_source/init.js +95 -0
  50. data/spec/support/js_source/matching.coffee +444 -0
  51. data/spec/support/js_source/matching.js +685 -0
  52. data/spec/support/js_source/scoring.coffee +270 -0
  53. data/spec/support/js_source/scoring.js +390 -0
  54. data/spec/support/matcher.rb +35 -0
  55. data/spec/zxcvbn_spec.rb +49 -0
  56. data/zxcvbn-ruby.gemspec +20 -0
  57. 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