strong_password 0.0.4 → 0.0.10
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/.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
|