strong_password 0.0.3 → 0.0.9
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.
- checksums.yaml +5 -5
- data/.travis.yml +17 -0
- data/CHANGELOG +23 -1
- data/Gemfile +6 -2
- data/README.md +35 -23
- data/Rakefile +5 -0
- data/lib/active_model/validations/password_strength_validator.rb +3 -3
- data/lib/strong_password.rb +2 -2
- data/lib/strong_password/dictionary_adjuster.rb +985 -98
- data/lib/strong_password/entropy_calculator.rb +9 -9
- data/lib/strong_password/locale/en.yml +1 -1
- data/lib/strong_password/nist_bonus_bits.rb +5 -5
- data/lib/strong_password/password_variants.rb +21 -58
- data/lib/strong_password/qwerty_adjuster.rb +58 -56
- data/lib/strong_password/railtie.rb +1 -1
- data/lib/strong_password/strength_checker.rb +41 -20
- data/lib/strong_password/version.rb +1 -1
- data/spec/spec_helper.rb +8 -2
- data/spec/strong_password/dictionary_adjuster_spec.rb +20 -20
- data/spec/strong_password/entropy_calculator_spec.rb +4 -4
- data/spec/strong_password/nist_bonus_bits_spec.rb +5 -5
- data/spec/strong_password/password_variants_spec.rb +14 -14
- data/spec/strong_password/qwerty_adjuster_spec.rb +26 -16
- data/spec/strong_password/strength_checker_spec.rb +33 -9
- data/spec/validation/strength_validator_spec.rb +12 -8
- data/strong_password.gemspec +2 -1
- metadata +29 -15
@@ -8,7 +8,7 @@ module StrongPassword
|
|
8
8
|
bits(password)
|
9
9
|
end
|
10
10
|
end
|
11
|
-
|
11
|
+
|
12
12
|
# The basic NIST entropy calculation is based solely
|
13
13
|
# on the length of the password in question.
|
14
14
|
def self.bits(password)
|
@@ -24,7 +24,7 @@ module StrongPassword
|
|
24
24
|
end
|
25
25
|
bits + NistBonusBits.bonus_bits(password)
|
26
26
|
end
|
27
|
-
|
27
|
+
|
28
28
|
# A modified version of the basic entropy calculation
|
29
29
|
# which lowers the amount of entropy gained for each
|
30
30
|
# repeated character in the password
|
@@ -36,9 +36,9 @@ module StrongPassword
|
|
36
36
|
end
|
37
37
|
bits + NistBonusBits.bonus_bits(password)
|
38
38
|
end
|
39
|
-
|
39
|
+
|
40
40
|
private
|
41
|
-
|
41
|
+
|
42
42
|
def self.bit_value_at_position(position, base = 1)
|
43
43
|
if position > 19
|
44
44
|
return base
|
@@ -50,17 +50,17 @@ module StrongPassword
|
|
50
50
|
return 4
|
51
51
|
end
|
52
52
|
end
|
53
|
-
|
53
|
+
|
54
54
|
class EntropyResolver
|
55
55
|
BASE_VALUE = 1
|
56
56
|
REPEAT_WEAKENING_FACTOR = 0.75
|
57
|
-
|
57
|
+
|
58
58
|
attr_reader :char_multiplier
|
59
|
-
|
59
|
+
|
60
60
|
def initialize
|
61
61
|
@char_multiplier = {}
|
62
62
|
end
|
63
|
-
|
63
|
+
|
64
64
|
# Returns the current entropy value for a character and weakens the entropy
|
65
65
|
# for future calls for the same character.
|
66
66
|
def entropy_for(char)
|
@@ -73,4 +73,4 @@ module StrongPassword
|
|
73
73
|
end
|
74
74
|
end
|
75
75
|
end
|
76
|
-
end
|
76
|
+
end
|
@@ -1,26 +1,26 @@
|
|
1
1
|
module StrongPassword
|
2
2
|
module NistBonusBits
|
3
3
|
@@bonus_bits_for_password = {}
|
4
|
-
|
4
|
+
|
5
5
|
# NIST password strength rules allow up to 6 bonus bits for mixed case and non-alphabetic
|
6
6
|
def self.bonus_bits(password)
|
7
7
|
@@bonus_bits_for_password[password] ||= begin
|
8
8
|
calculate_bonus_bits_for(password)
|
9
9
|
end
|
10
10
|
end
|
11
|
-
|
11
|
+
|
12
12
|
# This smells bad as it's only used for testing...
|
13
13
|
def self.reset_bonus_cache!
|
14
14
|
@@bonus_bits_for_password = {}
|
15
15
|
end
|
16
|
-
|
16
|
+
|
17
17
|
def self.calculate_bonus_bits_for(password)
|
18
18
|
upper = !!(password =~ /[[:upper:]]/)
|
19
19
|
lower = !!(password =~ /[[:lower:]]/)
|
20
20
|
numeric = !!(password =~ /[[:digit:]]/)
|
21
21
|
other = !!(password =~ /[^a-zA-Z0-9 ]/)
|
22
22
|
space = !!(password =~ / /)
|
23
|
-
|
23
|
+
|
24
24
|
# I had this condensed to nested ternaries but that shit was ugly
|
25
25
|
bonus_bits = if upper && lower && other && numeric
|
26
26
|
6
|
@@ -42,4 +42,4 @@ module StrongPassword
|
|
42
42
|
bonus_bits
|
43
43
|
end
|
44
44
|
end
|
45
|
-
end
|
45
|
+
end
|
@@ -1,6 +1,8 @@
|
|
1
1
|
module StrongPassword
|
2
2
|
module PasswordVariants
|
3
|
-
|
3
|
+
LEET_SPEAK_REGEXP = /[\@\!\$1234567890]/
|
4
|
+
|
5
|
+
LEET_SPEAK = {
|
4
6
|
"@" => "a",
|
5
7
|
"!" => "i",
|
6
8
|
"$" => "s",
|
@@ -16,37 +18,9 @@ module StrongPassword
|
|
16
18
|
"0" => "o"
|
17
19
|
}
|
18
20
|
|
19
|
-
|
20
|
-
"@" => "a",
|
21
|
-
"!" => "i",
|
22
|
-
"$" => "s",
|
23
|
-
"1" => "l",
|
24
|
-
"2" => "z",
|
25
|
-
"3" => "e",
|
26
|
-
"4" => "a",
|
27
|
-
"5" => "s",
|
28
|
-
"6" => "g",
|
29
|
-
"7" => "t",
|
30
|
-
"8" => "b",
|
31
|
-
"9" => "g",
|
32
|
-
"0" => "o"
|
33
|
-
}
|
21
|
+
LEET_SPEAK_ALT = LEET_SPEAK.dup.merge!("1" => "l")
|
34
22
|
|
35
|
-
|
36
|
-
"z" => "",
|
37
|
-
"x" => "",
|
38
|
-
"c" => "",
|
39
|
-
"v" => "",
|
40
|
-
"b" => "",
|
41
|
-
"n" => "",
|
42
|
-
"m" => "",
|
43
|
-
"," => "",
|
44
|
-
"." => "",
|
45
|
-
"/" => "",
|
46
|
-
"<" => "",
|
47
|
-
">" => "",
|
48
|
-
"?" => ""
|
49
|
-
}
|
23
|
+
BOTTOM_ROW_REGEXP = /[zxcvbnm,\.\/\<\>\?]/
|
50
24
|
|
51
25
|
KEYBOARDMAP_DOWNRIGHT = {
|
52
26
|
"a" => "z",
|
@@ -105,10 +79,10 @@ module StrongPassword
|
|
105
79
|
"p" => "l",
|
106
80
|
"-" => "p"
|
107
81
|
}
|
108
|
-
|
82
|
+
|
109
83
|
# Returns all variants of a given password including the password itself
|
110
84
|
def self.all_variants(password)
|
111
|
-
passwords = [password.
|
85
|
+
passwords = [password.downcase]
|
112
86
|
passwords += keyboard_shift_variants(password)
|
113
87
|
passwords += leet_speak_variants(password)
|
114
88
|
passwords.uniq
|
@@ -116,38 +90,27 @@ module StrongPassword
|
|
116
90
|
|
117
91
|
# Returns all keyboard shifted variants of a given password
|
118
92
|
def self.keyboard_shift_variants(password)
|
119
|
-
password = password.
|
120
|
-
variants = []
|
121
|
-
|
122
|
-
if (password == password.tr(KEYBOARDMAP_DOWN_NOSHIFT.keys.join, KEYBOARDMAP_DOWN_NOSHIFT.values.join))
|
123
|
-
variant = password.tr(KEYBOARDMAP_DOWNRIGHT.keys.join, KEYBOARDMAP_DOWNRIGHT.values.join)
|
124
|
-
variants << variant
|
125
|
-
variants << variant.reverse
|
93
|
+
password = password.downcase
|
126
94
|
|
127
|
-
|
128
|
-
|
129
|
-
variants << variant.reverse
|
130
|
-
end
|
131
|
-
variants
|
95
|
+
return [] if password.match(BOTTOM_ROW_REGEXP)
|
96
|
+
variants(password, KEYBOARDMAP_DOWNRIGHT, KEYBOARDMAP_DOWNLEFT)
|
132
97
|
end
|
133
98
|
|
134
99
|
# Returns all leet speak variants of a given password
|
135
100
|
def self.leet_speak_variants(password)
|
136
|
-
password = password.
|
137
|
-
variants = []
|
101
|
+
password = password.downcase
|
138
102
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
103
|
+
return [] if !password.match(LEET_SPEAK_REGEXP)
|
104
|
+
variants(password, LEET_SPEAK, LEET_SPEAK_ALT)
|
105
|
+
end
|
106
|
+
|
107
|
+
private
|
144
108
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
109
|
+
def self.variants(password, *mappings)
|
110
|
+
mappings.flat_map do |map|
|
111
|
+
variant = password.tr(map.keys.join, map.values.join)
|
112
|
+
[variant, variant.reverse]
|
149
113
|
end
|
150
|
-
variants
|
151
114
|
end
|
152
115
|
end
|
153
|
-
end
|
116
|
+
end
|
@@ -1,73 +1,75 @@
|
|
1
1
|
module StrongPassword
|
2
2
|
class QwertyAdjuster
|
3
3
|
QWERTY_STRINGS = [
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
4
|
+
'1234567890-',
|
5
|
+
'qwertyuiop',
|
6
|
+
'asdfghjkl;',
|
7
|
+
'zxcvbnm,./',
|
8
8
|
"1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik,9ol.0p;/-['=]:?_{\"+}",
|
9
|
-
|
9
|
+
'1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p',
|
10
10
|
"qazwsxedcrfvtgbyhnujmik,ol.p;/-['=]:?_{\"+}",
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
]
|
19
|
-
|
20
|
-
attr_reader :
|
21
|
-
|
22
|
-
def initialize(
|
23
|
-
@
|
11
|
+
'qazwsxedcrfvtgbyhnujmikolp',
|
12
|
+
']"/=[;.-pl,0okm9ijn8uhb7ygv6tfc5rdx4esz3wa2q1',
|
13
|
+
'pl0okm9ijn8uhb7ygv6tfc5rdx4esz3wa2q1',
|
14
|
+
']"/[;.pl,okmijnuhbygvtfcrdxeszwaq',
|
15
|
+
'plokmijnuhbygvtfcrdxeszwaq',
|
16
|
+
'014725836914702583697894561230258/369*+-*/',
|
17
|
+
'abcdefghijklmnopqrstuvwxyz'
|
18
|
+
].freeze
|
19
|
+
|
20
|
+
attr_reader :min_entropy, :entropy_threshhold
|
21
|
+
|
22
|
+
def initialize(min_entropy: 18, entropy_threshhold: min_entropy)
|
23
|
+
@min_entropy = min_entropy
|
24
|
+
@entropy_threshhold = entropy_threshhold
|
24
25
|
end
|
25
|
-
|
26
|
-
def is_strong?(
|
27
|
-
adjusted_entropy(
|
26
|
+
|
27
|
+
def is_strong?(base_password)
|
28
|
+
adjusted_entropy(base_password) >= min_entropy
|
28
29
|
end
|
29
|
-
|
30
|
-
def is_weak?(
|
31
|
-
!is_strong?(
|
30
|
+
|
31
|
+
def is_weak?(base_password)
|
32
|
+
!is_strong?(base_password)
|
32
33
|
end
|
33
|
-
|
34
|
+
|
34
35
|
# Returns the minimum entropy for the password's qwerty locality
|
35
36
|
# adjustments. If a threshhold is specified we will bail
|
36
37
|
# early to avoid unnecessary processing.
|
37
|
-
def adjusted_entropy(
|
38
|
+
def adjusted_entropy(base_password)
|
38
39
|
revpassword = base_password.reverse
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
return min_entropy if min_entropy < entropy_threshhold
|
52
|
-
end
|
53
|
-
end
|
54
|
-
min_entropy
|
40
|
+
lowest_entropy = [EntropyCalculator.calculate(base_password), EntropyCalculator.calculate(revpassword)].min
|
41
|
+
# If our entropy is already lower than we care about then there's no reason to look further.
|
42
|
+
return lowest_entropy if lowest_entropy < entropy_threshhold
|
43
|
+
|
44
|
+
qpassword = mask_qwerty_strings(base_password)
|
45
|
+
lowest_entropy = [lowest_entropy, EntropyCalculator.calculate(qpassword)].min if qpassword != base_password
|
46
|
+
# Bail early if our entropy on the base password's masked qwerty value is less than our threshold.
|
47
|
+
return lowest_entropy if lowest_entropy < entropy_threshhold
|
48
|
+
|
49
|
+
qrevpassword = mask_qwerty_strings(revpassword)
|
50
|
+
lowest_entropy = [lowest_entropy, EntropyCalculator.calculate(qrevpassword)].min if qrevpassword != revpassword
|
51
|
+
lowest_entropy
|
55
52
|
end
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
def
|
60
|
-
|
61
|
-
|
62
|
-
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def all_qwerty_strings
|
57
|
+
@all_qwerty_strings ||= Regexp.union(QWERTY_STRINGS.flat_map do |qwerty_string|
|
58
|
+
gen_qw_strings(qwerty_string)
|
59
|
+
end)
|
60
|
+
end
|
61
|
+
|
62
|
+
def gen_qw_strings(qwerty_string)
|
63
|
+
6.downto(3).flat_map do |z|
|
63
64
|
y = qwerty_string.length - z
|
64
|
-
(0..y).
|
65
|
-
|
66
|
-
masked_password = masked_password.sub(str, '*')
|
65
|
+
(0..y).map do |x|
|
66
|
+
qwerty_string[x, z].sub('-', '\\-')
|
67
67
|
end
|
68
|
-
|
69
|
-
|
70
|
-
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def mask_qwerty_strings(password)
|
72
|
+
password.gsub(all_qwerty_strings, '*')
|
71
73
|
end
|
72
74
|
end
|
73
|
-
end
|
75
|
+
end
|
@@ -1 +1 @@
|
|
1
|
-
require 'rails/railtie'
|
1
|
+
require 'rails/railtie' if defined?(Rails)
|
@@ -1,37 +1,58 @@
|
|
1
1
|
module StrongPassword
|
2
2
|
class StrengthChecker
|
3
3
|
BASE_ENTROPY = 18
|
4
|
-
|
5
|
-
|
4
|
+
PASSWORD_LIMIT = 1_000
|
5
|
+
EXTRA_WORDS_LIMIT = 1_000
|
6
6
|
|
7
|
-
|
8
|
-
|
7
|
+
attr_reader :min_entropy, :use_dictionary, :min_word_length, :extra_dictionary_words
|
8
|
+
|
9
|
+
def initialize(min_entropy: BASE_ENTROPY, use_dictionary: false, min_word_length: 4, extra_dictionary_words: [])
|
10
|
+
@min_entropy = min_entropy
|
11
|
+
@use_dictionary = use_dictionary
|
12
|
+
@min_word_length = min_word_length
|
13
|
+
@extra_dictionary_words = extra_dictionary_words
|
9
14
|
end
|
10
|
-
|
11
|
-
def is_weak?(
|
12
|
-
!is_strong?(
|
13
|
-
use_dictionary: use_dictionary,
|
14
|
-
min_word_length: min_word_length,
|
15
|
-
extra_dictionary_words: extra_dictionary_words)
|
15
|
+
|
16
|
+
def is_weak?(password)
|
17
|
+
!is_strong?(password)
|
16
18
|
end
|
17
19
|
|
18
|
-
def is_strong?(
|
20
|
+
def is_strong?(password)
|
21
|
+
base_password = password.dup[0...PASSWORD_LIMIT]
|
19
22
|
weak = (EntropyCalculator.calculate(base_password) < min_entropy) ||
|
20
23
|
(EntropyCalculator.calculate(base_password.downcase) < min_entropy) ||
|
21
|
-
(
|
24
|
+
(qwerty_adjuster.is_weak?(base_password))
|
22
25
|
if !weak && use_dictionary
|
23
|
-
return
|
24
|
-
min_word_length: min_word_length,
|
25
|
-
extra_dictionary_words: extra_dictionary_words)
|
26
|
+
return dictionary_adjuster.is_strong?(base_password)
|
26
27
|
else
|
27
28
|
return !weak
|
28
29
|
end
|
29
30
|
end
|
30
|
-
|
31
|
-
def calculate_entropy(
|
32
|
-
|
33
|
-
|
31
|
+
|
32
|
+
def calculate_entropy(password)
|
33
|
+
base_password = password.dup[0...PASSWORD_LIMIT]
|
34
|
+
extra_dictionary_words.collect! { |w| w[0...EXTRA_WORDS_LIMIT] }
|
35
|
+
entropies = [
|
36
|
+
EntropyCalculator.calculate(base_password),
|
37
|
+
EntropyCalculator.calculate(base_password.downcase),
|
38
|
+
qwerty_adjuster.adjusted_entropy(base_password)
|
39
|
+
]
|
40
|
+
entropies << dictionary_adjuster.adjusted_entropy(base_password) if use_dictionary
|
34
41
|
entropies.min
|
35
42
|
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def qwerty_adjuster
|
47
|
+
@qwerty_adjuster ||= QwertyAdjuster.new(min_entropy: min_entropy)
|
48
|
+
end
|
49
|
+
|
50
|
+
def dictionary_adjuster
|
51
|
+
@dictionary_adjuster ||= DictionaryAdjuster.new(
|
52
|
+
min_word_length: min_word_length,
|
53
|
+
extra_dictionary_words: extra_dictionary_words,
|
54
|
+
min_entropy: min_entropy
|
55
|
+
)
|
56
|
+
end
|
36
57
|
end
|
37
|
-
end
|
58
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,8 +1,14 @@
|
|
1
|
+
require 'simplecov'
|
2
|
+
require 'simplecov-console'
|
3
|
+
SimpleCov.formatter = SimpleCov::Formatter::Console
|
4
|
+
SimpleCov.start
|
5
|
+
|
1
6
|
require 'bundler/setup'
|
2
|
-
require '
|
7
|
+
require 'pry'
|
3
8
|
require 'active_model'
|
9
|
+
require 'strong_password'
|
4
10
|
|
5
11
|
RSpec.configure do |config|
|
6
12
|
config.expect_with(:rspec) {|c| c.syntax = :expect}
|
7
13
|
config.order = :random
|
8
|
-
end
|
14
|
+
end
|