strong_password 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|