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