zxcvbn-ruby 0.0.3 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.travis.yml +12 -0
- data/CHANGELOG.md +42 -0
- data/CODE_OF_CONDUCT.md +130 -0
- data/Gemfile +8 -1
- data/Guardfile +26 -0
- data/{LICENSE → LICENSE.txt} +0 -0
- data/README.md +165 -9
- data/Rakefile +5 -1
- data/lib/zxcvbn.rb +10 -36
- data/lib/zxcvbn/crack_time.rb +44 -42
- data/lib/zxcvbn/data.rb +29 -0
- data/lib/zxcvbn/dictionary_ranker.rb +0 -2
- data/lib/zxcvbn/entropy.rb +3 -1
- data/lib/zxcvbn/feedback.rb +10 -0
- data/lib/zxcvbn/feedback_giver.rb +133 -0
- data/lib/zxcvbn/matchers/date.rb +2 -0
- data/lib/zxcvbn/matchers/dictionary.rb +2 -0
- data/lib/zxcvbn/matchers/digits.rb +2 -0
- data/lib/zxcvbn/matchers/l33t.rb +2 -2
- data/lib/zxcvbn/matchers/regex_helpers.rb +2 -0
- data/lib/zxcvbn/matchers/repeat.rb +2 -0
- data/lib/zxcvbn/matchers/sequences.rb +2 -0
- data/lib/zxcvbn/matchers/spatial.rb +2 -0
- data/lib/zxcvbn/matchers/year.rb +2 -0
- data/lib/zxcvbn/math.rb +2 -2
- data/lib/zxcvbn/omnimatch.rb +14 -3
- data/lib/zxcvbn/password_strength.rb +7 -3
- data/lib/zxcvbn/score.rb +1 -1
- data/lib/zxcvbn/scorer.rb +11 -0
- data/lib/zxcvbn/tester.rb +43 -0
- data/lib/zxcvbn/version.rb +1 -1
- data/spec/dictionary_ranker_spec.rb +2 -2
- data/spec/feedback_giver_spec.rb +212 -0
- data/spec/matchers/date_spec.rb +8 -8
- data/spec/matchers/dictionary_spec.rb +25 -14
- data/spec/matchers/digits_spec.rb +3 -3
- data/spec/matchers/l33t_spec.rb +15 -13
- data/spec/matchers/repeat_spec.rb +6 -6
- data/spec/matchers/sequences_spec.rb +5 -5
- data/spec/matchers/spatial_spec.rb +8 -8
- data/spec/matchers/year_spec.rb +3 -3
- data/spec/omnimatch_spec.rb +2 -2
- data/spec/scoring/crack_time_spec.rb +13 -13
- data/spec/scoring/entropy_spec.rb +28 -25
- data/spec/scoring/math_spec.rb +22 -18
- data/spec/support/matcher.rb +1 -1
- data/spec/tester_spec.rb +99 -0
- data/spec/zxcvbn_spec.rb +14 -39
- data/zxcvbn-ruby.gemspec +11 -0
- metadata +34 -29
data/lib/zxcvbn/crack_time.rb
CHANGED
@@ -1,51 +1,53 @@
|
|
1
|
-
module Zxcvbn
|
2
|
-
|
3
|
-
|
1
|
+
module Zxcvbn
|
2
|
+
module CrackTime
|
3
|
+
SINGLE_GUESS = 0.010
|
4
|
+
NUM_ATTACKERS = 100
|
4
5
|
|
5
|
-
|
6
|
+
SECONDS_PER_GUESS = SINGLE_GUESS / NUM_ATTACKERS
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
def entropy_to_crack_time(entropy)
|
9
|
+
0.5 * (2 ** entropy) * SECONDS_PER_GUESS
|
10
|
+
end
|
10
11
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
12
|
+
def crack_time_to_score(seconds)
|
13
|
+
case
|
14
|
+
when seconds < 10**2
|
15
|
+
0
|
16
|
+
when seconds < 10**4
|
17
|
+
1
|
18
|
+
when seconds < 10**6
|
19
|
+
2
|
20
|
+
when seconds < 10**8
|
21
|
+
3
|
22
|
+
else
|
23
|
+
4
|
24
|
+
end
|
23
25
|
end
|
24
|
-
end
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
27
|
+
def display_time(seconds)
|
28
|
+
minute = 60
|
29
|
+
hour = minute * 60
|
30
|
+
day = hour * 24
|
31
|
+
month = day * 31
|
32
|
+
year = month * 12
|
33
|
+
century = year * 100
|
33
34
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
35
|
+
case
|
36
|
+
when seconds < minute
|
37
|
+
'instant'
|
38
|
+
when seconds < hour
|
39
|
+
"#{1 + (seconds / minute).ceil} minutes"
|
40
|
+
when seconds < day
|
41
|
+
"#{1 + (seconds / hour).ceil} hours"
|
42
|
+
when seconds < month
|
43
|
+
"#{1 + (seconds / day).ceil} days"
|
44
|
+
when seconds < year
|
45
|
+
"#{1 + (seconds / month).ceil} months"
|
46
|
+
when seconds < century
|
47
|
+
"#{1 + (seconds / year).ceil} years"
|
48
|
+
else
|
49
|
+
'centuries'
|
50
|
+
end
|
49
51
|
end
|
50
52
|
end
|
51
53
|
end
|
data/lib/zxcvbn/data.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'zxcvbn/dictionary_ranker'
|
3
|
+
|
4
|
+
module Zxcvbn
|
5
|
+
class Data
|
6
|
+
def initialize
|
7
|
+
@ranked_dictionaries = DictionaryRanker.rank_dictionaries(
|
8
|
+
"english" => read_word_list("english.txt"),
|
9
|
+
"female_names" => read_word_list("female_names.txt"),
|
10
|
+
"male_names" => read_word_list("male_names.txt"),
|
11
|
+
"passwords" => read_word_list("passwords.txt"),
|
12
|
+
"surnames" => read_word_list("surnames.txt")
|
13
|
+
)
|
14
|
+
@adjacency_graphs = JSON.load(DATA_PATH.join('adjacency_graphs.json').read)
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :ranked_dictionaries, :adjacency_graphs
|
18
|
+
|
19
|
+
def add_word_list(name, list)
|
20
|
+
@ranked_dictionaries[name] = DictionaryRanker.rank_dictionary(list)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def read_word_list(file)
|
26
|
+
DATA_PATH.join("frequency_lists", file).read.split
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/zxcvbn/entropy.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'zxcvbn/math'
|
2
|
+
|
1
3
|
module Zxcvbn::Entropy
|
2
4
|
include Zxcvbn::Math
|
3
5
|
|
@@ -127,7 +129,7 @@ module Zxcvbn::Entropy
|
|
127
129
|
|
128
130
|
# estimate the ngpumber of possible patterns w/ token length or less with number of turns or less.
|
129
131
|
(2..token_length).each do |i|
|
130
|
-
possible_turns = [turns, i -1].min
|
132
|
+
possible_turns = [turns, i - 1].min
|
131
133
|
(1..possible_turns).each do |j|
|
132
134
|
possibilities += nCk(i - 1, j - 1) * starting_positions * average_degree ** j
|
133
135
|
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'zxcvbn/entropy'
|
2
|
+
require 'zxcvbn/feedback'
|
3
|
+
|
4
|
+
module Zxcvbn
|
5
|
+
class FeedbackGiver
|
6
|
+
NAME_DICTIONARIES = %w[surnames male_names female_names].freeze
|
7
|
+
|
8
|
+
DEFAULT_FEEDBACK = Feedback.new(
|
9
|
+
suggestions: [
|
10
|
+
'Use a few words, avoid common phrases',
|
11
|
+
'No need for symbols, digits, or uppercase letters'
|
12
|
+
]
|
13
|
+
).freeze
|
14
|
+
|
15
|
+
EMPTY_FEEDBACK = Feedback.new.freeze
|
16
|
+
|
17
|
+
def self.get_feedback(score, sequence)
|
18
|
+
# starting feedback
|
19
|
+
return DEFAULT_FEEDBACK if sequence.length.zero?
|
20
|
+
|
21
|
+
# no feedback if score is good or great.
|
22
|
+
return EMPTY_FEEDBACK if score > 2
|
23
|
+
|
24
|
+
# tie feedback to the longest match for longer sequences
|
25
|
+
longest_match = sequence[0]
|
26
|
+
for match in sequence[1..-1]
|
27
|
+
longest_match = match if match.token.length > longest_match.token.length
|
28
|
+
end
|
29
|
+
|
30
|
+
feedback = get_match_feedback(longest_match, sequence.length == 1)
|
31
|
+
extra_feedback = 'Add another word or two. Uncommon words are better.'
|
32
|
+
|
33
|
+
if feedback.nil?
|
34
|
+
feedback = Feedback.new(suggestions: [extra_feedback])
|
35
|
+
else
|
36
|
+
feedback.suggestions.unshift extra_feedback
|
37
|
+
end
|
38
|
+
|
39
|
+
feedback
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.get_match_feedback(match, is_sole_match)
|
43
|
+
case match.pattern
|
44
|
+
when 'dictionary'
|
45
|
+
get_dictionary_match_feedback match, is_sole_match
|
46
|
+
|
47
|
+
when 'spatial'
|
48
|
+
layout = match.graph.upcase
|
49
|
+
warning = if match.turns == 1
|
50
|
+
'Straight rows of keys are easy to guess'
|
51
|
+
else
|
52
|
+
'Short keyboard patterns are easy to guess'
|
53
|
+
end
|
54
|
+
|
55
|
+
Feedback.new(
|
56
|
+
warning: warning,
|
57
|
+
suggestions: [
|
58
|
+
'Use a longer keyboard pattern with more turns'
|
59
|
+
]
|
60
|
+
)
|
61
|
+
|
62
|
+
when 'repeat'
|
63
|
+
Feedback.new(
|
64
|
+
warning: 'Repeats like "aaa" are easy to guess',
|
65
|
+
suggestions: [
|
66
|
+
'Avoid repeated words and characters'
|
67
|
+
]
|
68
|
+
)
|
69
|
+
|
70
|
+
when 'sequence'
|
71
|
+
Feedback.new(
|
72
|
+
warning: 'Sequences like abc or 6543 are easy to guess',
|
73
|
+
suggestions: [
|
74
|
+
'Avoid sequences'
|
75
|
+
]
|
76
|
+
)
|
77
|
+
|
78
|
+
when 'date'
|
79
|
+
Feedback.new(
|
80
|
+
warning: 'Dates are often easy to guess',
|
81
|
+
suggestions: [
|
82
|
+
'Avoid dates and years that are associated with you'
|
83
|
+
]
|
84
|
+
)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.get_dictionary_match_feedback(match, is_sole_match)
|
89
|
+
warning = if match.dictionary_name == 'passwords'
|
90
|
+
if is_sole_match && !match.l33t && !match.reversed
|
91
|
+
if match.rank <= 10
|
92
|
+
'This is a top-10 common password'
|
93
|
+
elsif match.rank <= 100
|
94
|
+
'This is a top-100 common password'
|
95
|
+
else
|
96
|
+
'This is a very common password'
|
97
|
+
end
|
98
|
+
else
|
99
|
+
'This is similar to a commonly used password'
|
100
|
+
end
|
101
|
+
elsif NAME_DICTIONARIES.include? match.dictionary_name
|
102
|
+
if is_sole_match
|
103
|
+
'Names and surnames by themselves are easy to guess'
|
104
|
+
else
|
105
|
+
'Common names and surnames are easy to guess'
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
suggestions = []
|
110
|
+
word = match.token
|
111
|
+
|
112
|
+
if word =~ Zxcvbn::Entropy::START_UPPER
|
113
|
+
suggestions.push "Capitalization doesn't help very much"
|
114
|
+
elsif word =~ Zxcvbn::Entropy::ALL_UPPER && word.downcase != word
|
115
|
+
suggestions.push(
|
116
|
+
'All-uppercase is almost as easy to guess as all-lowercase'
|
117
|
+
)
|
118
|
+
end
|
119
|
+
|
120
|
+
if match.l33t
|
121
|
+
suggestions.push(
|
122
|
+
"Predictable substitutions like '@' instead of 'a' \
|
123
|
+
don't help very much"
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
Feedback.new(
|
128
|
+
warning: warning,
|
129
|
+
suggestions: suggestions
|
130
|
+
)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
data/lib/zxcvbn/matchers/date.rb
CHANGED
data/lib/zxcvbn/matchers/l33t.rb
CHANGED
@@ -31,8 +31,8 @@ module Zxcvbn
|
|
31
31
|
token = password[match.i..match.j]
|
32
32
|
next if token.downcase == match.matched_word.downcase
|
33
33
|
match_substitutions = {}
|
34
|
-
substitution.each do |
|
35
|
-
match_substitutions[
|
34
|
+
substitution.each do |s, letter|
|
35
|
+
match_substitutions[s] = letter if token.include?(s)
|
36
36
|
end
|
37
37
|
match.l33t = true
|
38
38
|
match.token = password[match.i..match.j]
|
data/lib/zxcvbn/matchers/year.rb
CHANGED
data/lib/zxcvbn/math.rb
CHANGED
@@ -41,14 +41,14 @@ module Zxcvbn
|
|
41
41
|
end
|
42
42
|
|
43
43
|
def average_degree_for_graph(graph_name)
|
44
|
-
graph =
|
44
|
+
graph = data.adjacency_graphs[graph_name]
|
45
45
|
degrees = graph.map { |_, neighbors| neighbors.compact.size }
|
46
46
|
sum = degrees.inject(0, :+)
|
47
47
|
sum.to_f / graph.size
|
48
48
|
end
|
49
49
|
|
50
50
|
def starting_positions_for_graph(graph_name)
|
51
|
-
|
51
|
+
data.adjacency_graphs[graph_name].length
|
52
52
|
end
|
53
53
|
end
|
54
54
|
end
|
data/lib/zxcvbn/omnimatch.rb
CHANGED
@@ -1,6 +1,17 @@
|
|
1
|
+
require 'zxcvbn/dictionary_ranker'
|
2
|
+
require 'zxcvbn/matchers/dictionary'
|
3
|
+
require 'zxcvbn/matchers/l33t'
|
4
|
+
require 'zxcvbn/matchers/spatial'
|
5
|
+
require 'zxcvbn/matchers/digits'
|
6
|
+
require 'zxcvbn/matchers/repeat'
|
7
|
+
require 'zxcvbn/matchers/sequences'
|
8
|
+
require 'zxcvbn/matchers/year'
|
9
|
+
require 'zxcvbn/matchers/date'
|
10
|
+
|
1
11
|
module Zxcvbn
|
2
12
|
class Omnimatch
|
3
|
-
def initialize
|
13
|
+
def initialize(data)
|
14
|
+
@data = data
|
4
15
|
@matchers = build_matchers
|
5
16
|
end
|
6
17
|
|
@@ -24,14 +35,14 @@ module Zxcvbn
|
|
24
35
|
|
25
36
|
def build_matchers
|
26
37
|
matchers = []
|
27
|
-
dictionary_matchers =
|
38
|
+
dictionary_matchers = @data.ranked_dictionaries.map do |name, dictionary|
|
28
39
|
Matchers::Dictionary.new(name, dictionary)
|
29
40
|
end
|
30
41
|
l33t_matcher = Matchers::L33t.new(dictionary_matchers)
|
31
42
|
matchers += dictionary_matchers
|
32
43
|
matchers += [
|
33
44
|
l33t_matcher,
|
34
|
-
Matchers::Spatial.new(
|
45
|
+
Matchers::Spatial.new(@data.adjacency_graphs),
|
35
46
|
Matchers::Digits.new,
|
36
47
|
Matchers::Repeat.new,
|
37
48
|
Matchers::Sequences.new,
|
@@ -1,10 +1,13 @@
|
|
1
1
|
require 'benchmark'
|
2
|
+
require 'zxcvbn/feedback_giver'
|
3
|
+
require 'zxcvbn/omnimatch'
|
4
|
+
require 'zxcvbn/scorer'
|
2
5
|
|
3
6
|
module Zxcvbn
|
4
7
|
class PasswordStrength
|
5
|
-
def initialize
|
6
|
-
@omnimatch = Omnimatch.new
|
7
|
-
@scorer = Scorer.new
|
8
|
+
def initialize(data)
|
9
|
+
@omnimatch = Omnimatch.new(data)
|
10
|
+
@scorer = Scorer.new(data)
|
8
11
|
end
|
9
12
|
|
10
13
|
def test(password, user_inputs = [])
|
@@ -15,6 +18,7 @@ module Zxcvbn
|
|
15
18
|
result = @scorer.minimum_entropy_match_sequence(password, matches)
|
16
19
|
end
|
17
20
|
result.calc_time = calc_time
|
21
|
+
result.feedback = FeedbackGiver.get_feedback(result.score, result.match_sequence)
|
18
22
|
result
|
19
23
|
end
|
20
24
|
end
|
data/lib/zxcvbn/score.rb
CHANGED