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 Year
4
+ include RegexHelpers
5
+
6
+ YEAR_REGEX = /19\d\d|200\d|201\d/
7
+
8
+ def matches(password)
9
+ result = []
10
+ re_match_all(YEAR_REGEX, password) do |match|
11
+ match.pattern = 'year'
12
+ result << match
13
+ end
14
+ result
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,63 @@
1
+ module Zxcvbn
2
+ module Math
3
+ def bruteforce_cardinality(password)
4
+ is_type_of = {}
5
+
6
+ password.each_byte do |ordinal|
7
+ case ordinal
8
+ when (48..57)
9
+ is_type_of['digits'] = true
10
+ when (65..90)
11
+ is_type_of['upper'] = true
12
+ when (97..122)
13
+ is_type_of['lower'] = true
14
+ else
15
+ is_type_of['symbols'] = true
16
+ end
17
+ end
18
+
19
+ cardinality = 0
20
+ cardinality += 10 if is_type_of['digits']
21
+ cardinality += 26 if is_type_of['upper']
22
+ cardinality += 26 if is_type_of['lower']
23
+ cardinality += 33 if is_type_of['symbols']
24
+ cardinality
25
+ end
26
+
27
+ def lg(n)
28
+ ::Math.log(n, 2)
29
+ end
30
+
31
+ def nCk(n, k)
32
+ return 0 if k > n
33
+ return 1 if k == 0
34
+ r = 1
35
+ (1..k).each do |d|
36
+ r = r * n
37
+ r = r / d
38
+ n -= 1
39
+ end
40
+ r
41
+ end
42
+
43
+ def min(a, b)
44
+ a < b ? a : b
45
+ end
46
+
47
+ def average_degree_for_graph(graph_name)
48
+ graph = Zxcvbn::ADJACENCY_GRAPHS[graph_name]
49
+ average = 0.0
50
+
51
+ graph.each do |key, neighbors|
52
+ average += neighbors.compact.length
53
+ end
54
+
55
+ average /= graph.keys.length
56
+ average
57
+ end
58
+
59
+ def starting_positions_for_graph(graph_name)
60
+ Zxcvbn::ADJACENCY_GRAPHS[graph_name].length
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,49 @@
1
+ require 'json'
2
+ require 'yaml'
3
+ require 'pathname'
4
+
5
+ module Zxcvbn
6
+ class Omnimatch
7
+ def initialize
8
+ @matchers = build_matchers
9
+ end
10
+
11
+ def matches(password, user_inputs = [])
12
+ result = []
13
+ (@matchers + user_input_matchers(user_inputs)).each do |matcher|
14
+ result += matcher.matches(password)
15
+ end
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def user_input_matchers(user_inputs)
22
+ return [] unless user_inputs.any?
23
+ user_ranked_dictionary = DictionaryRanker.rank_dictionary(user_inputs)
24
+ dictionary_matcher = Matchers::Dictionary.new('user_inputs', user_ranked_dictionary)
25
+ l33t_matcher = Matchers::L33t.new([dictionary_matcher])
26
+ [dictionary_matcher, l33t_matcher]
27
+ end
28
+
29
+
30
+ def build_matchers
31
+ matchers = []
32
+ dictionary_matchers = RANKED_DICTIONARIES.map do |name, dictionary|
33
+ Matchers::Dictionary.new(name, dictionary)
34
+ end
35
+ l33t_matcher = Matchers::L33t.new(dictionary_matchers)
36
+ matchers += dictionary_matchers
37
+ matchers += [
38
+ l33t_matcher,
39
+ Matchers::Spatial.new(ADJACENCY_GRAPHS),
40
+ Matchers::Digits.new,
41
+ Matchers::Repeat.new,
42
+ Matchers::Sequences.new,
43
+ Matchers::Year.new,
44
+ Matchers::Date.new
45
+ ]
46
+ matchers
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,21 @@
1
+ require 'benchmark'
2
+
3
+ module Zxcvbn
4
+ class PasswordStrength
5
+ def initialize
6
+ @omnimatch = Omnimatch.new
7
+ @scorer = Scorer.new
8
+ end
9
+
10
+ def test(password, user_inputs = [])
11
+ password = password || ''
12
+ result = nil
13
+ calc_time = Benchmark.realtime do
14
+ matches = @omnimatch.matches(password, user_inputs)
15
+ result = @scorer.minimum_entropy_match_sequence(password, matches)
16
+ end
17
+ result.calc_time = calc_time
18
+ result
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ module Zxcvbn
2
+ class Score
3
+ attr_accessor :entropy, :crack_time, :crack_time_display, :score, :pattern,
4
+ :match_sequence, :password, :calc_time
5
+
6
+ def initialize(options = {})
7
+ @entropy = options[:entropy]
8
+ @crack_time = options[:crack_time]
9
+ @crack_time_display = options[:crack_time_display]
10
+ @score = options[:score]
11
+ @match_sequence = options[:match_sequence]
12
+ @password = options[:password]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,84 @@
1
+ module Zxcvbn
2
+ class Scorer
3
+ include Entropy
4
+ include CrackTime
5
+
6
+ def minimum_entropy_match_sequence(password, matches)
7
+ bruteforce_cardinality = bruteforce_cardinality(password) # e.g. 26 for lowercase
8
+ up_to_k = [] # minimum entropy up to k.
9
+ backpointers = [] # for the optimal sequence of matches up to k, holds the final match (match.j == k). null means the sequence ends w/ a brute-force character.
10
+ (0...password.length).each do |k|
11
+ # starting scenario to try and beat: adding a brute-force character to the minimum entropy sequence at k-1.
12
+ previous_k_entropy = k == 0 ? 0 : up_to_k[k-1]
13
+ up_to_k[k] = previous_k_entropy + lg(bruteforce_cardinality)
14
+ backpointers[k] = nil
15
+ matches.each do |match|
16
+ next unless match.j == k
17
+ i, j = match.i, match.j
18
+ # see if best entropy up to i-1 + entropy of this match is less than the current minimum at j.
19
+ previous_i_entropy = i > 0 ? up_to_k[i-1] : 0
20
+ candidate_entropy = previous_i_entropy + calc_entropy(match)
21
+ if up_to_k[j] && candidate_entropy < up_to_k[j]
22
+ up_to_k[j] = candidate_entropy
23
+ backpointers[j] = match
24
+ end
25
+ end
26
+ end
27
+ # walk backwards and decode the best sequence
28
+ match_sequence = []
29
+ k = password.length - 1
30
+ while k >= 0
31
+ match = backpointers[k]
32
+ if match
33
+ match_sequence.push match
34
+ k = match.i - 1
35
+ else
36
+ k -= 1
37
+ end
38
+ end
39
+ match_sequence.reverse!
40
+
41
+ # fill in the blanks between pattern matches with bruteforce "matches"
42
+ # that way the match sequence fully covers the password: match1.j == match2.i - 1 for every adjacent match1, match2.
43
+ make_bruteforce_match = lambda do |i, j|
44
+ Match.new(
45
+ :pattern => 'bruteforce',
46
+ :i => i,
47
+ :j => j,
48
+ :token => password[i..j],
49
+ :entropy => lg(bruteforce_cardinality ** (j - i + 1)),
50
+ :cardinality => bruteforce_cardinality
51
+ )
52
+ end
53
+
54
+ k = 0
55
+ match_sequence_copy = []
56
+ match_sequence.each do |match|
57
+ i, j = match.i, match.j
58
+ if i - k > 0
59
+ debugger if i == 0
60
+ match_sequence_copy << make_bruteforce_match.call(k, i - 1)
61
+ end
62
+ k = j + 1
63
+ match_sequence_copy.push match
64
+ end
65
+ if k < password.length
66
+ match_sequence_copy.push make_bruteforce_match.call(k, password.length - 1)
67
+ end
68
+ match_sequence = match_sequence_copy
69
+
70
+ min_entropy = up_to_k[password.length - 1] || 0 # or 0 corner case is for an empty password ''
71
+ crack_time = entropy_to_crack_time(min_entropy)
72
+
73
+ # final result object
74
+ Score.new(
75
+ :password => password,
76
+ :entropy => min_entropy.round(3),
77
+ :match_sequence => match_sequence,
78
+ :crack_time => crack_time.round(3),
79
+ :crack_time_display => display_time(crack_time),
80
+ :score => crack_time_to_score(crack_time)
81
+ )
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,3 @@
1
+ module Zxcvbn
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,109 @@
1
+ require 'spec_helper'
2
+
3
+ describe Zxcvbn::Matchers::Date do
4
+ let(:matcher) { subject }
5
+
6
+ {
7
+ ' ' => 'testing02 12 1997',
8
+ '-' => 'testing02-12-1997',
9
+ '/' => 'testing02/12/1997',
10
+ '\\' => 'testing02\12\1997',
11
+ '_' => 'testing02_12_1997',
12
+ '.' => 'testing02.12.1997'
13
+ }.each do |separator, password|
14
+ context "with #{separator} seperator" do
15
+ let(:matches) { matcher.matches(password) }
16
+
17
+ it 'finds matches' do
18
+ matches.should_not be_empty
19
+ end
20
+
21
+ it 'finds the correct matches' do
22
+ matches.count.should eq 1
23
+ matches[0].token.should eq %w[ 02 12 1997 ].join(separator)
24
+ matches[0].separator.should eq separator
25
+ matches[0].day.should eq 2
26
+ matches[0].month.should eq 12
27
+ matches[0].year.should eq 1997
28
+ end
29
+ end
30
+ end
31
+
32
+ # context 'without separator' do
33
+ # context '5 digit date' do
34
+ # let(:matches) { matcher.matches('13192boo') }
35
+
36
+ # it 'finds matches' do
37
+ # matches.should_not be_empty
38
+ # end
39
+
40
+ # it 'finds the correct matches' do
41
+ # matches.count.should eq 2
42
+ # matches[0].token.should eq '13192'
43
+ # matches[0].separator.should eq ''
44
+ # matches[0].day.should eq 13
45
+ # matches[0].month.should eq 1
46
+ # matches[0].year.should eq 1992
47
+
48
+ # matches[1].token.should eq '13192'
49
+ # matches[1].separator.should eq ''
50
+ # matches[1].day.should eq 31
51
+ # matches[1].month.should eq 1
52
+ # matches[1].year.should eq 1992
53
+ # end
54
+ # end
55
+ # end
56
+
57
+ # describe '#extract_dates' do
58
+ # {
59
+ # '1234' => [
60
+ # {:year => 2012, :month => 3, :day => 4},
61
+ # {:year => 1934, :month => 2, :day => 1},
62
+ # {:year => 1934, :month => 1, :day => 2}
63
+ # ],
64
+ # '12345' => [
65
+ # {:year => 1945, :month => 3, :day => 12},
66
+ # {:year => 1945, :month => 12, :day => 3},
67
+ # {:year => 1945, :month => 1, :day => 23}
68
+ # ],
69
+ # '54321' => [
70
+ # {:year => 1954, :month => 3, :day => 21}
71
+ # ],
72
+ # '151290' => [
73
+ # {:year => 1990, :month => 12, :day => 15}
74
+ # ],
75
+ # '901215' => [
76
+ # {:year => 1990, :month => 12, :day => 15}
77
+ # ],
78
+ # '1511990' => [
79
+ # {:year => 1990, :month => 1, :day => 15}
80
+ # ]
81
+ # }.each do |token, expected_candidates|
82
+ # it "finds the correct candidates for #{token}" do
83
+ # matcher.extract_dates(token).should match_array expected_candidates
84
+ # end
85
+ # end
86
+ # end
87
+
88
+ # describe '#expand_year' do
89
+ # {
90
+ # 12 => 2012,
91
+ # 01 => 2001,
92
+ # 15 => 2015,
93
+ # 19 => 2019,
94
+ # 20 => 1920
95
+ # }.each do |small, expanded|
96
+ # it "expands #{small} to #{expanded}" do
97
+ # matcher.expand_year(small).should eq expanded
98
+ # end
99
+ # end
100
+ # end
101
+
102
+ context 'invalid date' do
103
+ let(:matches) { matcher.matches('testing0.x.1997') }
104
+
105
+ it 'doesnt match' do
106
+ matches.should be_empty
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,14 @@
1
+ require 'spec_helper'
2
+
3
+ describe Zxcvbn::Matchers::Dictionary do
4
+ let(:matcher) { described_class.new('english', dictionary) }
5
+ let(:dictionary) { Zxcvbn::RANKED_DICTIONARIES['english'] }
6
+
7
+ it 'finds all the matches' do
8
+ matches = matcher.matches('whatisinit')
9
+ matches.count.should == 14
10
+ expected_matches = ['wha', 'what', 'ha', 'hat', 'a', 'at', 'tis', 'i', 'is',
11
+ 'sin', 'i', 'in', 'i', 'it']
12
+ matches.map(&:matched_word).should == expected_matches
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ require 'spec_helper'
2
+
3
+ describe Zxcvbn::Matchers::Digits do
4
+ let(:matcher) { subject }
5
+ let(:matches) { matcher.matches('testing1239xx9712') }
6
+
7
+ it 'sets the pattern name' do
8
+ matches.all? { |m| m.pattern == 'digits' }.should be_true
9
+ end
10
+
11
+ it 'finds the correct matches' do
12
+ matches.count.should == 2
13
+ matches[0].token.should eq '1239'
14
+ end
15
+ end
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ describe Zxcvbn::Matchers::L33t do
4
+ let(:matcher) { described_class.new([dictionary_matcher]) }
5
+ let(:dictionary) { Zxcvbn::RANKED_DICTIONARIES['english'] }
6
+ let(:dictionary_matcher) { Zxcvbn::Matchers::Dictionary.new('english', dictionary) }
7
+
8
+ describe '#relevant_l33t_substitutions' do
9
+ it 'returns relevant l33t substitutions' do
10
+ matcher.relevent_l33t_subtable('p@ssw1rd24').should eq(
11
+ {'a' => ['4', '@'], 'i' => ['1'], 'l' => ['1'], 'z' => ['2']}
12
+ )
13
+ end
14
+ end
15
+
16
+ describe 'possible l33t substitutions' do
17
+ context 'with 2 possible substitutions' do
18
+ it 'returns the correct possible substitutions' do
19
+ substitutions = {'a' => ['@'], 'i' => ['1']}
20
+ matcher.l33t_subs(substitutions).should match_array([
21
+ {'@' => 'a', '1' => 'i'}
22
+ ])
23
+ end
24
+
25
+ it 'returns the correct possible substitutions with multiple options' do
26
+ substitutions = {'a' => ['@', '4'], 'i' => ['1']}
27
+ matcher.l33t_subs(substitutions).should match_array([
28
+ {'@' => 'a', '1' => 'i'},
29
+ {'4' => 'a', '1' => 'i'}
30
+ ])
31
+ end
32
+ end
33
+
34
+ context 'with 3 possible substitutions' do
35
+ it 'returns the correct possible substitutions' do
36
+ substitutions = {'a' => ['@'], 'i' => ['1'], 'z' => ['3']}
37
+ matcher.l33t_subs(substitutions).should match_array([
38
+ {'@' => 'a', '1' => 'i', '3' => 'z'}
39
+ ])
40
+ end
41
+ end
42
+
43
+ context 'with 4 possible substitutions' do
44
+ it 'returns the correct possible substitutions' do
45
+ substitutions = {'a' => ['@'], 'i' => ['1'], 'z' => ['3'], 'b' => ['8']}
46
+ matcher.l33t_subs(substitutions).should match_array([
47
+ {'@' => 'a', '1' => 'i', '3' => 'z', '8' => 'b'}
48
+ ])
49
+ end
50
+ end
51
+ end
52
+
53
+ describe '#matches' do
54
+ let(:matches) { matcher.matches('p@ssword') }
55
+ # it doesn't match on 'password' because that's not in the english
56
+ # dictionary/frequency list
57
+
58
+ it 'finds the correct matches' do
59
+ matches.map(&:matched_word).should eq([
60
+ 'pas',
61
+ 'a',
62
+ 'as',
63
+ 'ass'
64
+ ])
65
+ end
66
+
67
+ it 'sets the token correctly on those matches' do
68
+ matches.map(&:token).should eq([
69
+ 'p@s',
70
+ '@',
71
+ '@s',
72
+ '@ss'
73
+ ])
74
+ end
75
+
76
+ it 'sets the substituions used' do
77
+ matches.map(&:sub).should eq([
78
+ {'@' => 'a'},
79
+ {'@' => 'a'},
80
+ {'@' => 'a'},
81
+ {'@' => 'a'}
82
+ ])
83
+ end
84
+ end
85
+ end