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
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