strong_password 0.0.1
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 +7 -0
- data/.gitignore +17 -0
- data/CHANGELOG +3 -0
- data/Gemfile +14 -0
- data/LICENSE.txt +22 -0
- data/README.md +157 -0
- data/Rakefile +1 -0
- data/lib/active_model/validations/password_strength_validator.rb +43 -0
- data/lib/strong_password/dictionary_adjuster.rb +117 -0
- data/lib/strong_password/entropy_calculator.rb +76 -0
- data/lib/strong_password/locale/en.yml +5 -0
- data/lib/strong_password/nist_bonus_bits.rb +45 -0
- data/lib/strong_password/password_variants.rb +153 -0
- data/lib/strong_password/qwerty_adjuster.rb +73 -0
- data/lib/strong_password/railtie.rb +1 -0
- data/lib/strong_password/strength_checker.rb +37 -0
- data/lib/strong_password/version.rb +3 -0
- data/lib/strong_password.rb +15 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/strong_password/dictionary_adjuster_spec.rb +66 -0
- data/spec/strong_password/entropy_calculator_spec.rb +65 -0
- data/spec/strong_password/nist_bonus_bits_spec.rb +45 -0
- data/spec/strong_password/password_variants_spec.rb +67 -0
- data/spec/strong_password/qwerty_adjuster_spec.rb +45 -0
- data/spec/strong_password/strength_checker_spec.rb +64 -0
- data/spec/validation/strength_validator_spec.rb +150 -0
- data/strong_password.gemspec +24 -0
- metadata +121 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1db75b147a9e2bc0833c5de0a098fd8b7584d3b5
|
4
|
+
data.tar.gz: 6046844808e8bcf7636cb1865e0218198739a0c1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0b7544150289fbe379361be135494cc78bdf8d063dffe68f9f9384c82b50dafacb37a3f0ea6c3dcac41ead516595eeea99ecb1b748abb6884a2af8d4a3f6316e
|
7
|
+
data.tar.gz: 121dbd08326dccbfb8eacac4378d0db274b0eadd716ae773c009ff3f626811030d59be998470c834cec21fa6244f3cff93e1401b7ef4eb2b0cfd84b45c6aab9e
|
data/.gitignore
ADDED
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Brian McManus
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
# StrongPassword
|
2
|
+
|
3
|
+
StrongPassword provides entropy-based password strength checking for your apps.
|
4
|
+
|
5
|
+
The idea stemmed from the
|
6
|
+
"[Checking Password Strength](http://stackoverflow.com/questions/549/the-definitive-guide-to-forms-based-website-authentication)"
|
7
|
+
section on StackOverflow.
|
8
|
+
|
9
|
+
It is an adaptation of a [PHP algorithm](http://cubicspot.blogspot.com/2011/11/how-to-calculate-password-strength.html)
|
10
|
+
developed by Thomas Hruska.
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
NOTE: StrongPassword requires the use of Ruby 2.0. Upgrade if you haven't already!
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
gem 'strong_password'
|
19
|
+
|
20
|
+
And then execute:
|
21
|
+
|
22
|
+
$ bundle
|
23
|
+
|
24
|
+
Or install it yourself as:
|
25
|
+
|
26
|
+
$ gem install strong_password
|
27
|
+
|
28
|
+
## Usage
|
29
|
+
|
30
|
+
StrongPassword is designed primarily to be used with Rails and ActiveModel validations but can also be
|
31
|
+
used standalone if needed.
|
32
|
+
|
33
|
+
### With Rails/ActiveModel Validations
|
34
|
+
|
35
|
+
StrongPassword adds a custom validator for use with ActiveModel validations. Your model does NOT need to be an ActiveRecord
|
36
|
+
model, it simply has to be an object that includes `ActiveModel::Validations`. Use it like so:
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
class User
|
40
|
+
include ActiveModel::Validations
|
41
|
+
|
42
|
+
# Basic usage. Defaults to minimum entropy of 18 and no dictionary checking
|
43
|
+
validates :password, password_strength: true
|
44
|
+
# Minimum entropy can be specified as min_entropy
|
45
|
+
validates :password, password_strength: {min_entropy: 40}
|
46
|
+
# Specifies that we want to use dictionary checking
|
47
|
+
validates :password, password_strength: {use_dictionary: true}
|
48
|
+
# Specifies the minimum size of things that should count as words. Defaults to 4.
|
49
|
+
validates :password, password_strength: {use_dictionary: true, min_word_length: 6}
|
50
|
+
# Specifies that we want to use dictionary checking and adds 'other', 'common', and 'words' to the dictionary we are checking against.
|
51
|
+
validates :password, password_strength: {extra_dictionary_words: ['other', 'common', 'words'], use_dictionary: true}
|
52
|
+
# You can also specify a method name to pull the extra words from
|
53
|
+
validates :password, password_strength: {extra_dictionary_words: :my_extra_words, use_dictionary: true}
|
54
|
+
# Alternative way to request password strength validation on a field
|
55
|
+
validates_password_strength :password
|
56
|
+
|
57
|
+
# Return an array of words to add to the dictionary we check against.
|
58
|
+
def my_extra_words
|
59
|
+
['extra', 'words', 'here', 'too']
|
60
|
+
end
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
The default validation message is "%{attribute} is too weak". If you would like to customize that you can override it in your locale file
|
65
|
+
by setting a value for this key:
|
66
|
+
|
67
|
+
```yml
|
68
|
+
en:
|
69
|
+
errors:
|
70
|
+
messages:
|
71
|
+
password:
|
72
|
+
password_strength: "%{attribute} is a terrible password, try again!"
|
73
|
+
```
|
74
|
+
|
75
|
+
### Standalone
|
76
|
+
|
77
|
+
StrongPassword can also be used standalone if you need to. There are a few helper methods for determining whether a
|
78
|
+
password is strong or not. You can also directly access the entropy calculations if you want.
|
79
|
+
|
80
|
+
```text
|
81
|
+
2.0.0p0 :001 > checker = StrongPassword::StrengthChecker.new('password')
|
82
|
+
=> #<StrongPassword::StrengthChecker:0x007fcd7c939b48 @base_password="password">
|
83
|
+
2.0.0p0 :002 > checker.is_strong?
|
84
|
+
=> false
|
85
|
+
2.0.0p0 :003 > checker.is_weak?
|
86
|
+
=> true
|
87
|
+
2.0.0p0 :004 > checker.is_strong?(min_entropy: 2)
|
88
|
+
=> true
|
89
|
+
2.0.0p0 :005 > checker.calculate_entropy
|
90
|
+
=> 15.5
|
91
|
+
2.0.0p0 :006 > checker.calculate_entropy(use_dictionary: true)
|
92
|
+
=> 2
|
93
|
+
```
|
94
|
+
|
95
|
+
## Details
|
96
|
+
|
97
|
+
So what exactly does StrongPassword do for you? If you're really interested you should [read this article](http://cubicspot.blogspot.com/2011/11/how-to-calculate-password-strength.html)
|
98
|
+
from the original author of the algorithm.
|
99
|
+
|
100
|
+
For the most part StrongPassword stays true to the original but is refactored and tested. I will try to summarize what
|
101
|
+
the algorithm does here but reading the code is probably the best way to really get a feel for it.
|
102
|
+
|
103
|
+
The basic premise is to use the NIST suggested rules for entropy calculation as follows:
|
104
|
+
|
105
|
+
* The first byte counts as 4 bits.
|
106
|
+
* The next 7 bytes count as 2 bits each.
|
107
|
+
* The next 12 bytes count as 1.5 bits each.
|
108
|
+
* Anything beyond that counts as 1 bit each.
|
109
|
+
* Mixed case + non-alphanumeric = up to 6 extra bits.
|
110
|
+
|
111
|
+
We actually use a modified version of this calculation that degrades the entropy gained for repeat characters by 75% for
|
112
|
+
every appearance in the password. The original NIST calculation is still available in `StrongPassword::EntropyCalculator`
|
113
|
+
but is generally not used in favor of this stricter (albeit slower) version.
|
114
|
+
|
115
|
+
So the EntropyCalculator can calculate the entropy of our password for us. What else does the algorithm do?
|
116
|
+
|
117
|
+
In general the algorithm tries to find the lowest entropy possible for the password by running it against various
|
118
|
+
filters. If the lowest entropy we can find for a password is less than the minimum entropy in use by `StrongPassword::StrengthChecker`
|
119
|
+
then we consider the password to be weak. The default minimum entropy is 18 bits.
|
120
|
+
|
121
|
+
The first thing we try is a lowercased version of the password.
|
122
|
+
|
123
|
+
Next we run the password through a `QwertyAdjuster` that tries to adjust the password's entropy by removing common
|
124
|
+
qwerty keyboard layout passwords such as 'qwertyuiop', '1234567890', or '/.,mnbvcxz'. It looks for these in chunks
|
125
|
+
between 3 and 6 in length and reduces the entropy of the password accordingly.
|
126
|
+
|
127
|
+
Finally, if specified, we will run the password through a `DictionaryAdjuster`. This is the slowest part of the
|
128
|
+
algorithm but adds significantly to the strength checking. Ideally this should be run against a large dictionary
|
129
|
+
of the most commong words in your language (e.g. a 300,000 word English dictionary). Currently I just have this
|
130
|
+
hard-coded to run against an embedded list of the 500 most common passwords. This is much faster than running
|
131
|
+
against a 300,000 dictionary file and still adds considerably to the strength checking. The password is checked
|
132
|
+
against all dictionary words over a minimum word length (default is 4). We also generate variations of
|
133
|
+
the password to check for common things like leet speak, and qwerty-shifted passwords. The lowest entropy found
|
134
|
+
for any variant is used. Essentially a password of "p4ssw0rd" will be treated as though it were just "password"
|
135
|
+
when looking for dictionary words and thus would find "password" and get an extremely low entropy value.
|
136
|
+
|
137
|
+
You can also supply a list of extra dictionary words to this method via the various `StrongPassword::StrengthChecker`
|
138
|
+
methods. Any words supplied will be treated as though they were common dictionary words. This lets you add
|
139
|
+
things like your user's first name, last name, and email address as common dictionary words so they will be
|
140
|
+
disallowed by the strength checker.
|
141
|
+
|
142
|
+
## Todo
|
143
|
+
|
144
|
+
1. Allow the dictionary of common words to be specified by the user. This is currently hard-coded to
|
145
|
+
the 500 most common passwords. That also means it's not a true "dictionary" check...
|
146
|
+
2. Add a common password adjuster that basically works like the existing DictionaryAdjuster but does
|
147
|
+
not stop at the first found word. Stopping at the first word make sense if you have a 300,000 word
|
148
|
+
dictionary of the English language but not so much when you're only talking about the 500 most
|
149
|
+
common passwords.
|
150
|
+
|
151
|
+
## Contributing
|
152
|
+
|
153
|
+
1. Fork it
|
154
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
155
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
156
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
157
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'strong_password'
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Validations
|
5
|
+
class PasswordStrengthValidator < ActiveModel::EachValidator
|
6
|
+
def validate_each(object, attribute, value)
|
7
|
+
ps = ::StrongPassword::StrengthChecker.new(value)
|
8
|
+
unless ps.is_strong?(strength_options(options, object))
|
9
|
+
object.errors.add(attribute, :'password.password_strength', options.merge(:value => value))
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def strength_options(options, object)
|
14
|
+
strength_options = options.dup
|
15
|
+
strength_options[:extra_dictionary_words] = extra_words_for_object(strength_options[:extra_dictionary_words], object)
|
16
|
+
strength_options.slice(:min_entropy, :use_dictionary, :min_word_length, :extra_dictionary_words)
|
17
|
+
end
|
18
|
+
|
19
|
+
def extra_words_for_object(extra_words, object)
|
20
|
+
return [] unless extra_words.present?
|
21
|
+
if object
|
22
|
+
if extra_words.respond_to?(:call)
|
23
|
+
extra_words = extra_words.call(object)
|
24
|
+
elsif extra_words.kind_of? Symbol
|
25
|
+
extra_words = object.send(extra_words)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
extra_words || []
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module HelperMethods
|
33
|
+
# class User < ActiveRecord::Base
|
34
|
+
# validates_password_strength :password
|
35
|
+
# validates_password_strength :password, extra_dictionary_words: :extra_words
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
def validates_password_strength(*attr_names)
|
39
|
+
validates_with PasswordStrengthValidator, _merge_attributes(attr_names)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
module StrongPassword
|
2
|
+
class DictionaryAdjuster
|
3
|
+
COMMON_PASSWORDS = ["123456","password","12345678","1234","pussy","12345","dragon","qwerty",
|
4
|
+
"696969","mustang","letmein","baseball","master","michael","football","shadow","monkey","abc123",
|
5
|
+
"pass","6969","jordan","harley","ranger","iwantu","jennifer","hunter","2000","test","batman",
|
6
|
+
"trustno1","thomas","tigger","robert","access","love","buster","1234567","soccer","hockey","killer",
|
7
|
+
"george","sexy","andrew","charlie","superman","asshole","dallas","jessica","panties","pepper",
|
8
|
+
"1111","austin","william","daniel","golfer","summer","heather","hammer","yankees","joshua","maggie",
|
9
|
+
"biteme","enter","ashley","thunder","cowboy","silver","richard","orange","merlin","michelle",
|
10
|
+
"corvette","bigdog","cheese","matthew","121212","patrick","martin","freedom","ginger","blowjob",
|
11
|
+
"nicole","sparky","yellow","camaro","secret","dick","falcon","taylor","111111","131313","123123",
|
12
|
+
"bitch","hello","scooter","please","","porsche","guitar","chelsea","black","diamond","nascar",
|
13
|
+
"jackson","cameron","654321","computer","amanda","wizard","xxxxxxxx","money","phoenix","mickey",
|
14
|
+
"bailey","knight","iceman","tigers","purple","andrea","horny","dakota","aaaaaa","player","sunshine",
|
15
|
+
"morgan","starwars","boomer","cowboys","edward","charles","girls","booboo","coffee","xxxxxx",
|
16
|
+
"bulldog","ncc1701","rabbit","peanut","john","johnny","gandalf","spanky","winter","brandy","compaq",
|
17
|
+
"carlos","tennis","james","mike","brandon","fender","anthony","blowme","ferrari","cookie","chicken",
|
18
|
+
"maverick","chicago","joseph","diablo","sexsex","hardcore","666666","willie","welcome","chris",
|
19
|
+
"panther","yamaha","justin","banana","driver","marine","angels","fishing","david","maddog","hooters",
|
20
|
+
"wilson","butthead","dennis","captain","bigdick","chester","smokey","xavier","steven","viking",
|
21
|
+
"snoopy","blue","eagles","winner","samantha","house","miller","flower","jack","firebird","butter",
|
22
|
+
"united","turtle","steelers","tiffany","zxcvbn","tomcat","golf","bond007","bear","tiger","doctor",
|
23
|
+
"gateway","gators","angel","junior","thx1138","porno","badboy","debbie","spider","melissa","booger",
|
24
|
+
"1212","flyers","fish","porn","matrix","teens","scooby","jason","walter","cumshot","boston","braves",
|
25
|
+
"yankee","lover","barney","victor","tucker","princess","mercedes","5150","doggie","zzzzzz","gunner",
|
26
|
+
"horney","bubba","2112","fred","johnson","xxxxx","tits","member","boobs","donald","bigdaddy","bronco",
|
27
|
+
"penis","voyager","rangers","birdie","trouble","white","topgun","bigtits","bitches","green","super",
|
28
|
+
"qazwsx","magic","lakers","rachel","slayer","scott","2222","asdf","video","london","7777","marlboro",
|
29
|
+
"srinivas","internet","action","carter","jasper","monster","teresa","jeremy","11111111","bill","crystal",
|
30
|
+
"peter","pussies","cock","beer","rocket","theman","oliver","prince","beach","amateur","7777777","muffin",
|
31
|
+
"redsox","star","testing","shannon","murphy","frank","hannah","dave","eagle1","11111","mother","nathan",
|
32
|
+
"raiders","steve","forever","angela","viper","ou812","jake","lovers","suckit","gregory","buddy",
|
33
|
+
"whatever","young","nicholas","lucky","helpme","jackie","monica","midnight","college","baby","brian",
|
34
|
+
"mark","startrek","sierra","leather","232323","4444","beavis","bigcock","happy","sophie","ladies",
|
35
|
+
"naughty","giants","booty","blonde","golden","0","fire","sandra","pookie","packers","einstein",
|
36
|
+
"dolphins","0","chevy","winston","warrior","sammy","slut","8675309","zxcvbnm","nipples","power",
|
37
|
+
"victoria","asdfgh","vagina","toyota","travis","hotdog","paris","rock","xxxx","extreme","redskins",
|
38
|
+
"erotic","dirty","ford","freddy","arsenal","access14","wolf","nipple","iloveyou","alex","florida",
|
39
|
+
"eric","legend","movie","success","rosebud","jaguar","great","cool","cooper","1313","scorpio",
|
40
|
+
"mountain","madison","987654","brazil","lauren","japan","naked","squirt","stars","apple","alexis",
|
41
|
+
"aaaa","bonnie","peaches","jasmine","kevin","matt","qwertyui","danielle","beaver","4321","4128",
|
42
|
+
"runner","swimming","dolphin","gordon","casper","stupid","shit","saturn","gemini","apples","august",
|
43
|
+
"3333","canada","blazer","cumming","hunting","kitty","rainbow","112233","arthur","cream","calvin",
|
44
|
+
"shaved","surfer","samson","kelly","paul","mine","king","racing","5555","eagle","hentai","newyork",
|
45
|
+
"little","redwings","smith","sticky","cocacola","animal","broncos","private","skippy","marvin",
|
46
|
+
"blondes","enjoy","girl","apollo","parker","qwert","time","sydney","women","voodoo","magnum",
|
47
|
+
"juice","abgrtyu","777777","dreams","maxwell","music","rush2112","russia","scorpion","rebecca",
|
48
|
+
"tester","mistress","phantom","billy","6666","albert"]
|
49
|
+
|
50
|
+
attr_reader :base_password
|
51
|
+
|
52
|
+
def initialize(password)
|
53
|
+
@base_password = password.dup.downcase
|
54
|
+
end
|
55
|
+
|
56
|
+
def is_strong?(min_entropy: 18, min_word_length: 4, extra_dictionary_words: [])
|
57
|
+
adjusted_entropy(entropy_threshhold: min_entropy,
|
58
|
+
min_word_length: min_word_length,
|
59
|
+
extra_dictionary_words: extra_dictionary_words) >= min_entropy
|
60
|
+
end
|
61
|
+
|
62
|
+
def is_weak?(min_entropy: 18, min_word_length: 4, extra_dictionary_words: [])
|
63
|
+
!is_strong?(min_entropy: min_entropy, min_word_length: min_word_length, extra_dictionary_words: extra_dictionary_words)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Returns the minimum entropy for the passwords dictionary adjustments.
|
67
|
+
# If a threshhold is specified we will bail early to avoid unnecessary
|
68
|
+
# processing.
|
69
|
+
# Note that we only check for the first matching word up to the threshhold if set.
|
70
|
+
# Subsequent matching words are not deductd.
|
71
|
+
def adjusted_entropy(min_word_length: 4, extra_dictionary_words: [], entropy_threshhold: -1)
|
72
|
+
dictionary_words = COMMON_PASSWORDS + extra_dictionary_words
|
73
|
+
min_entropy = EntropyCalculator.calculate(base_password)
|
74
|
+
# Process the passwords, while looking for possible matching words in the dictionary.
|
75
|
+
PasswordVariants.all_variants(base_password).each_with_index do |variant, num|
|
76
|
+
y = variant.length
|
77
|
+
x = -1
|
78
|
+
while x < y
|
79
|
+
x = x + 1
|
80
|
+
if ((variant[x] =~ /\w/) != nil)
|
81
|
+
next_non_word = variant.index(/\s/, x)
|
82
|
+
x2 = next_non_word ? next_non_word : variant.length + 1
|
83
|
+
found = false
|
84
|
+
while !found && (x2 - x >= min_word_length)
|
85
|
+
word = variant[x, min_word_length]
|
86
|
+
word += variant[(x + min_word_length)..x2].reverse.chars.inject('') {|memo, c| "(#{Regexp.quote(c)}#{memo})?"} if (x + min_word_length) <= y
|
87
|
+
results = dictionary_words.grep(/\b#{word}\b/)
|
88
|
+
if results.empty?
|
89
|
+
x = x + 1
|
90
|
+
numbits = EntropyCalculator.calculate('*' * x)
|
91
|
+
# If we have enough entropy at this length on a fully masked password with
|
92
|
+
# duplicates weakened then we can just bail on this variant
|
93
|
+
found = true if entropy_threshhold >= 0 && numbits >= entropy_threshhold
|
94
|
+
else
|
95
|
+
results.each do |match|
|
96
|
+
break unless match.present?
|
97
|
+
# Substitute a single * for matched portion of word and calculate entropy
|
98
|
+
variant = variant.sub(match.strip.sub('-', '\\-'), '*')
|
99
|
+
numbits = EntropyCalculator.calculate(variant)
|
100
|
+
min_entropy = [min_entropy, numbits].min
|
101
|
+
return min_entropy if min_entropy < entropy_threshhold
|
102
|
+
end
|
103
|
+
|
104
|
+
found = true
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
break if found
|
109
|
+
|
110
|
+
x = x2 - 1
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
return min_entropy
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module StrongPassword
|
2
|
+
module EntropyCalculator
|
3
|
+
# Calculates NIST entropy for a password.
|
4
|
+
def self.calculate(password, repeats_weakened = true)
|
5
|
+
if repeats_weakened
|
6
|
+
bits_with_repeats_weakened(password)
|
7
|
+
else
|
8
|
+
bits(password)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# The basic NIST entropy calculation is based solely
|
13
|
+
# on the length of the password in question.
|
14
|
+
def self.bits(password)
|
15
|
+
length = password.length
|
16
|
+
bits = if length > 20
|
17
|
+
4 + (7 * 2) + (12 * 1.5) + length - 20
|
18
|
+
elsif length > 8
|
19
|
+
4 + (7 * 2) + ((length - 8) * 1.5)
|
20
|
+
elsif length > 1
|
21
|
+
4 + ((length - 1) * 2)
|
22
|
+
else
|
23
|
+
(length == 1 ? 4 : 0)
|
24
|
+
end
|
25
|
+
bits + NistBonusBits.bonus_bits(password)
|
26
|
+
end
|
27
|
+
|
28
|
+
# A modified version of the basic entropy calculation
|
29
|
+
# which lowers the amount of entropy gained for each
|
30
|
+
# repeated character in the password
|
31
|
+
def self.bits_with_repeats_weakened(password)
|
32
|
+
resolver = EntropyResolver.new
|
33
|
+
bits = password.chars.each.with_index.inject(0) do |result, (char, index)|
|
34
|
+
char_value = resolver.entropy_for(char)
|
35
|
+
result += bit_value_at_position(index, char_value)
|
36
|
+
end
|
37
|
+
bits + NistBonusBits.bonus_bits(password)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def self.bit_value_at_position(position, base = 1)
|
43
|
+
if position > 19
|
44
|
+
return base
|
45
|
+
elsif position > 7
|
46
|
+
return base * 1.5
|
47
|
+
elsif position > 0
|
48
|
+
return base * 2
|
49
|
+
else
|
50
|
+
return 4
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class EntropyResolver
|
55
|
+
BASE_VALUE = 1
|
56
|
+
REPEAT_WEAKENING_FACTOR = 0.75
|
57
|
+
|
58
|
+
attr_reader :char_multiplier
|
59
|
+
|
60
|
+
def initialize
|
61
|
+
@char_multiplier = {}
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns the current entropy value for a character and weakens the entropy
|
65
|
+
# for future calls for the same character.
|
66
|
+
def entropy_for(char)
|
67
|
+
ordinal_value = char.ord
|
68
|
+
char_multiplier[ordinal_value] ||= BASE_VALUE
|
69
|
+
char_value = char_multiplier[ordinal_value]
|
70
|
+
# Weaken the value of this character for future occurrances
|
71
|
+
char_multiplier[ordinal_value] = char_multiplier[ordinal_value] * REPEAT_WEAKENING_FACTOR
|
72
|
+
return char_value
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module StrongPassword
|
2
|
+
module NistBonusBits
|
3
|
+
@@bonus_bits_for_password = {}
|
4
|
+
|
5
|
+
# NIST password strength rules allow up to 6 bonus bits for mixed case and non-alphabetic
|
6
|
+
def self.bonus_bits(password)
|
7
|
+
@@bonus_bits_for_password[password] ||= begin
|
8
|
+
calculate_bonus_bits_for(password)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# This smells bad as it's only used for testing...
|
13
|
+
def self.reset_bonus_cache!
|
14
|
+
@@bonus_bits_for_password = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.calculate_bonus_bits_for(password)
|
18
|
+
upper = !!(password =~ /[[:upper:]]/)
|
19
|
+
lower = !!(password =~ /[[:lower:]]/)
|
20
|
+
numeric = !!(password =~ /[[:digit:]]/)
|
21
|
+
other = !!(password =~ /[^a-zA-Z0-9 ]/)
|
22
|
+
space = !!(password =~ / /)
|
23
|
+
|
24
|
+
# I had this condensed to nested ternaries but that shit was ugly
|
25
|
+
bonus_bits = if upper && lower && other && numeric
|
26
|
+
6
|
27
|
+
elsif upper && lower && other && !numeric
|
28
|
+
5
|
29
|
+
elsif numeric && other && !upper && !lower
|
30
|
+
-2
|
31
|
+
elsif numeric && !other && !upper && !lower
|
32
|
+
-6
|
33
|
+
else
|
34
|
+
0
|
35
|
+
end
|
36
|
+
|
37
|
+
if !space
|
38
|
+
bonus_bits = bonus_bits - 2
|
39
|
+
elsif password.split(/\s+/).length > 3
|
40
|
+
bonus_bits = bonus_bits + 1
|
41
|
+
end
|
42
|
+
bonus_bits
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|