strong_password 0.0.4 → 0.0.10
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +2 -0
- data/.travis.yml +17 -0
- data/CHANGELOG +16 -1
- data/Gemfile +7 -2
- data/README.md +38 -24
- data/Rakefile +5 -0
- data/lib/active_model/validations/password_strength_validator.rb +4 -4
- data/lib/strong_password.rb +2 -2
- data/lib/strong_password/dictionary_adjuster.rb +16 -12
- data/lib/strong_password/entropy_calculator.rb +8 -8
- 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 +18 -55
- 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 +18 -18
- 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 +31 -8
- data/spec/validation/strength_validator_spec.rb +12 -8
- data/strong_password.gemspec +3 -2
- metadata +29 -15
@@ -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
|
@@ -3,36 +3,36 @@ require 'spec_helper'
|
|
3
3
|
module StrongPassword
|
4
4
|
describe DictionaryAdjuster do
|
5
5
|
describe '#is_strong?' do
|
6
|
-
let(:subject) { DictionaryAdjuster.new
|
6
|
+
let(:subject) { DictionaryAdjuster.new }
|
7
7
|
|
8
8
|
it 'returns true if the calculated entropy is >= the minimum' do
|
9
|
-
subject.
|
10
|
-
expect(subject.is_strong?).to
|
9
|
+
allow(subject).to receive_messages(adjusted_entropy: 18)
|
10
|
+
expect(subject.is_strong?('password')).to be_truthy
|
11
11
|
end
|
12
12
|
|
13
13
|
it 'returns false if the calculated entropy is < the minimum' do
|
14
|
-
subject.
|
15
|
-
expect(subject.is_strong?).to
|
14
|
+
allow(subject).to receive_messages(adjusted_entropy: 17)
|
15
|
+
expect(subject.is_strong?('password')).to be_falsey
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
19
|
describe '#is_weak?' do
|
20
|
-
let(:subject) { DictionaryAdjuster.new
|
20
|
+
let(:subject) { DictionaryAdjuster.new }
|
21
21
|
|
22
22
|
it 'returns the opposite of is_strong?' do
|
23
|
-
subject.
|
24
|
-
expect(subject.is_weak?).to
|
23
|
+
allow(subject).to receive_messages(is_strong?: true)
|
24
|
+
expect(subject.is_weak?('password')).to be_falsey
|
25
25
|
end
|
26
26
|
end
|
27
27
|
|
28
28
|
describe '#adjusted_entropy' do
|
29
|
-
before(:each) { NistBonusBits.
|
29
|
+
before(:each) { allow(NistBonusBits).to receive_messages(bonus_bits: 0) }
|
30
30
|
|
31
31
|
it 'checks against all variants of a given password' do
|
32
32
|
password = 'password'
|
33
|
-
adjuster = DictionaryAdjuster.new
|
34
|
-
PasswordVariants.
|
35
|
-
adjuster.adjusted_entropy
|
33
|
+
adjuster = DictionaryAdjuster.new
|
34
|
+
expect(PasswordVariants).to receive(:all_variants).with(password).and_return([])
|
35
|
+
adjuster.adjusted_entropy(password)
|
36
36
|
end
|
37
37
|
|
38
38
|
{
|
@@ -47,22 +47,22 @@ module StrongPassword
|
|
47
47
|
'asdf[]asdf' => 16 # Doesn't break with []s
|
48
48
|
}.each do |password, bits|
|
49
49
|
it "returns #{bits} for '#{password}'" do
|
50
|
-
expect(DictionaryAdjuster.new(password)
|
50
|
+
expect(DictionaryAdjuster.new.adjusted_entropy(password)).to eq(bits)
|
51
51
|
end
|
52
52
|
end
|
53
53
|
|
54
54
|
it 'allows extra words to be provided as an array' do
|
55
|
-
password = '
|
55
|
+
password = 'administratorWEQ@123'
|
56
56
|
base_entropy = EntropyCalculator.calculate(password)
|
57
|
-
expect(DictionaryAdjuster.new(
|
57
|
+
expect(DictionaryAdjuster.new(extra_dictionary_words: ['administrator']).adjusted_entropy(password)).not_to eq(base_entropy)
|
58
58
|
end
|
59
59
|
|
60
60
|
it 'allows minimum word length to be adjusted' do
|
61
61
|
password = '6969'
|
62
|
-
base_entropy = DictionaryAdjuster.new(password)
|
62
|
+
base_entropy = DictionaryAdjuster.new.adjusted_entropy(password)
|
63
63
|
# If we increase the min_word_length above the length of the password we should get a higher entropy
|
64
|
-
expect(DictionaryAdjuster.new(
|
64
|
+
expect(DictionaryAdjuster.new(min_word_length: 6).adjusted_entropy(password)).not_to be < base_entropy
|
65
65
|
end
|
66
66
|
end
|
67
67
|
end
|
68
|
-
end
|
68
|
+
end
|
@@ -3,7 +3,7 @@ require 'spec_helper'
|
|
3
3
|
module StrongPassword
|
4
4
|
describe EntropyCalculator do
|
5
5
|
describe '.bits' do
|
6
|
-
before(:each) { NistBonusBits.
|
6
|
+
before(:each) { allow(NistBonusBits).to receive_messages(bonus_bits: 0) }
|
7
7
|
{
|
8
8
|
'' => 0,
|
9
9
|
'*' => 4,
|
@@ -36,9 +36,9 @@ module StrongPassword
|
|
36
36
|
end
|
37
37
|
end
|
38
38
|
end
|
39
|
-
|
39
|
+
|
40
40
|
describe '.bits_with_repeats_weakened' do
|
41
|
-
before(:each) { NistBonusBits.
|
41
|
+
before(:each) { allow(NistBonusBits).to receive(:bonus_bits).and_return(0) }
|
42
42
|
{
|
43
43
|
'' => 0,
|
44
44
|
'*' => 4,
|
@@ -52,7 +52,7 @@ module StrongPassword
|
|
52
52
|
expect(subject.bits_with_repeats_weakened(password)).to eq(bits)
|
53
53
|
end
|
54
54
|
end
|
55
|
-
|
55
|
+
|
56
56
|
it 'returns the same value for repeated calls on a password' do
|
57
57
|
password = 'password'
|
58
58
|
initial_value = subject.bits_with_repeats_weakened(password)
|
@@ -5,19 +5,19 @@ module StrongPassword
|
|
5
5
|
describe '.bonus_bits' do
|
6
6
|
it 'calculates the bonus bits the first time for a given password' do
|
7
7
|
NistBonusBits.reset_bonus_cache!
|
8
|
-
NistBonusBits.
|
8
|
+
expect(NistBonusBits).to receive(:calculate_bonus_bits_for).and_return(1)
|
9
9
|
expect(NistBonusBits.bonus_bits('password')).to eq(1)
|
10
10
|
end
|
11
|
-
|
11
|
+
|
12
12
|
it 'caches the bonus bits for a password for later use' do
|
13
13
|
NistBonusBits.reset_bonus_cache!
|
14
|
-
NistBonusBits.
|
14
|
+
allow(NistBonusBits).to receive_messages(calculate_bonus_bits_for: 1)
|
15
15
|
NistBonusBits.bonus_bits('password')
|
16
|
-
NistBonusBits.
|
16
|
+
expect(NistBonusBits).not_to receive(:calculate_bonus_bits_for)
|
17
17
|
expect(NistBonusBits.bonus_bits('password')).to eq(1)
|
18
18
|
end
|
19
19
|
end
|
20
|
-
|
20
|
+
|
21
21
|
describe '.calculate_bonus_bits_for' do
|
22
22
|
{
|
23
23
|
'Ab$9' => 4,
|
@@ -6,59 +6,59 @@ module StrongPassword
|
|
6
6
|
it 'includes the lowercase password' do
|
7
7
|
expect(subject.all_variants("PASSWORD")).to include('password')
|
8
8
|
end
|
9
|
-
|
9
|
+
|
10
10
|
it 'includes keyboard shift variants' do
|
11
|
-
subject.
|
11
|
+
allow(subject).to receive_messages(keyboard_shift_variants: ['foo', 'bar'])
|
12
12
|
expect(subject.all_variants("password")).to include('foo', 'bar')
|
13
13
|
end
|
14
|
-
|
14
|
+
|
15
15
|
it 'includes leet speak variants' do
|
16
|
-
subject.
|
16
|
+
allow(subject).to receive_messages(leet_speak_variants: ['foo', 'bar'])
|
17
17
|
expect(subject.all_variants("password")).to include('foo', 'bar')
|
18
18
|
end
|
19
|
-
|
19
|
+
|
20
20
|
it 'does not mutate the password' do
|
21
21
|
password = 'PASSWORD'
|
22
22
|
subject.all_variants(password)
|
23
23
|
expect(password).to eq('PASSWORD')
|
24
24
|
end
|
25
25
|
end
|
26
|
-
|
26
|
+
|
27
27
|
describe '.keyboard_shift_variants' do
|
28
28
|
it 'returns no variants if password includes only bottom row characters' do
|
29
29
|
expect(subject.keyboard_shift_variants('zxcvbnm,./')).to eq([])
|
30
30
|
end
|
31
|
-
|
31
|
+
|
32
32
|
it 'maps down-right passwords' do
|
33
33
|
expect(subject.keyboard_shift_variants('qwerty')).to include('asdfgh')
|
34
34
|
end
|
35
|
-
|
35
|
+
|
36
36
|
it 'includes reversed down-right password' do
|
37
37
|
expect(subject.keyboard_shift_variants('qwerty')).to include('hgfdsa')
|
38
38
|
end
|
39
|
-
|
39
|
+
|
40
40
|
it 'maps down-left passwords' do
|
41
41
|
expect(subject.keyboard_shift_variants('sdfghj')).to include('zxcvbn')
|
42
42
|
end
|
43
|
-
|
43
|
+
|
44
44
|
it 'maps reversed down-left passwords' do
|
45
45
|
expect(subject.keyboard_shift_variants('sdfghj')).to include('nbvcxz')
|
46
46
|
end
|
47
47
|
end
|
48
|
-
|
48
|
+
|
49
49
|
describe '.leet_speak_variants' do
|
50
50
|
it 'returns no variants if the password includes no leet speak' do
|
51
51
|
expect(subject.leet_speak_variants('password')).to eq([])
|
52
52
|
end
|
53
|
-
|
53
|
+
|
54
54
|
it 'returns standard leet speak variants' do
|
55
55
|
expect(subject.leet_speak_variants('p4ssw0rd')).to include('password')
|
56
56
|
end
|
57
|
-
|
57
|
+
|
58
58
|
it 'returns reversed standard leet speak variants' do
|
59
59
|
expect(subject.leet_speak_variants('p4ssw0rd')).to include('drowssap')
|
60
60
|
end
|
61
|
-
|
61
|
+
|
62
62
|
it 'returns both i and l variants when given a 1' do
|
63
63
|
expect(subject.leet_speak_variants('h1b0b')).to include('hibob', 'hlbob')
|
64
64
|
end
|