zxcvbn-ruby 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
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
@@ -0,0 +1,13 @@
1
+ require 'ostruct'
2
+
3
+ module Zxcvbn
4
+ class Match < OpenStruct
5
+ def to_hash
6
+ hash = @table.dup
7
+ hash.keys.sort.each do |key|
8
+ hash[key.to_s] = hash.delete(key)
9
+ end
10
+ hash
11
+ end
12
+ end
13
+ end
@@ -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