zxcvbn-ruby 1.4.0 → 2.0.0
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 +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +322 -75
- data/data/frequency_lists/english_wikipedia.txt +30000 -0
- data/data/frequency_lists/female_names.txt +11 -114
- data/data/frequency_lists/male_names.txt +3 -24
- data/data/frequency_lists/passwords.txt +29623 -6764
- data/data/frequency_lists/surnames.txt +28 -30611
- data/data/frequency_lists/{english.txt → us_tv_and_film.txt} +147 -13532
- data/lib/zxcvbn/clock.rb +6 -0
- data/lib/zxcvbn/crack_time.rb +52 -18
- data/lib/zxcvbn/data.rb +61 -21
- data/lib/zxcvbn/dictionary_ranker.rb +10 -0
- data/lib/zxcvbn/feedback.rb +11 -6
- data/lib/zxcvbn/feedback_giver.rb +75 -50
- data/lib/zxcvbn/guesses.rb +208 -0
- data/lib/zxcvbn/match.rb +95 -15
- data/lib/zxcvbn/match_builder.rb +15 -0
- data/lib/zxcvbn/matchers/date.rb +171 -106
- data/lib/zxcvbn/matchers/dictionary.rb +15 -8
- data/lib/zxcvbn/matchers/digits.rb +6 -1
- data/lib/zxcvbn/matchers/l33t.rb +30 -34
- data/lib/zxcvbn/matchers/regex_helpers.rb +14 -6
- data/lib/zxcvbn/matchers/repeat.rb +47 -16
- data/lib/zxcvbn/matchers/sequences.rb +58 -48
- data/lib/zxcvbn/matchers/spatial.rb +22 -6
- data/lib/zxcvbn/matchers/year.rb +6 -1
- data/lib/zxcvbn/math.rb +15 -28
- data/lib/zxcvbn/omnimatch.rb +70 -22
- data/lib/zxcvbn/ruby.rb +3 -0
- data/lib/zxcvbn/score.rb +34 -10
- data/lib/zxcvbn/scorer.rb +142 -75
- data/lib/zxcvbn/tester.rb +58 -23
- data/lib/zxcvbn/tester_builder.rb +83 -0
- data/lib/zxcvbn/trie.rb +21 -0
- data/lib/zxcvbn/version.rb +1 -1
- data/lib/zxcvbn.rb +47 -7
- data/sig/zxcvbn/clock.rbs +5 -0
- data/sig/zxcvbn/crack_time.rbs +3 -5
- data/sig/zxcvbn/data.rbs +17 -8
- data/sig/zxcvbn/feedback.rbs +6 -4
- data/sig/zxcvbn/guesses.rbs +36 -0
- data/sig/zxcvbn/match.rbs +35 -33
- data/sig/zxcvbn/match_builder.rbs +36 -0
- data/sig/zxcvbn/matchers/date.rbs +23 -0
- data/sig/zxcvbn/matchers/dictionary.rbs +21 -0
- data/sig/zxcvbn/matchers/digits.rbs +11 -0
- data/sig/zxcvbn/matchers/l33t.rbs +27 -0
- data/sig/zxcvbn/matchers/regex_helpers.rbs +7 -0
- data/sig/zxcvbn/matchers/repeat.rbs +11 -0
- data/sig/zxcvbn/matchers/sequences.rbs +16 -0
- data/sig/zxcvbn/matchers/spatial.rbs +15 -0
- data/sig/zxcvbn/matchers/year.rbs +11 -0
- data/sig/zxcvbn/math.rbs +0 -4
- data/sig/zxcvbn/omnimatch.rbs +5 -2
- data/sig/zxcvbn/score.rbs +22 -11
- data/sig/zxcvbn/scorer.rbs +7 -8
- data/sig/zxcvbn/tester.rbs +5 -7
- data/sig/zxcvbn/tester_builder.rbs +16 -0
- data/sig/zxcvbn/trie.rbs +4 -0
- data/sig/zxcvbn.rbs +6 -4
- metadata +30 -13
- data/lib/zxcvbn/entropy.rb +0 -158
- data/lib/zxcvbn/matchers/new_l33t.rb +0 -118
- data/lib/zxcvbn/password_strength.rb +0 -27
- data/sig/zxcvbn/entropy.rbs +0 -33
- data/sig/zxcvbn/password_strength.rbs +0 -10
data/lib/zxcvbn/omnimatch.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'zxcvbn/dictionary_ranker'
|
|
4
|
+
require 'zxcvbn/trie'
|
|
4
5
|
require 'zxcvbn/matchers/dictionary'
|
|
5
6
|
require 'zxcvbn/matchers/l33t'
|
|
6
7
|
require 'zxcvbn/matchers/spatial'
|
|
@@ -11,40 +12,88 @@ require 'zxcvbn/matchers/year'
|
|
|
11
12
|
require 'zxcvbn/matchers/date'
|
|
12
13
|
|
|
13
14
|
module Zxcvbn
|
|
15
|
+
# Runs all registered matchers against a password and aggregates results.
|
|
16
|
+
#
|
|
17
|
+
# Includes dictionary, l33t, spatial, digit, repeat, sequence, year, date,
|
|
18
|
+
# and reverse-dictionary matchers. User-supplied word lists are wrapped in
|
|
19
|
+
# transient matchers for each call to {#matches}.
|
|
20
|
+
# @api private
|
|
14
21
|
class Omnimatch
|
|
22
|
+
# @param data [Data] loaded frequency lists and adjacency graphs
|
|
15
23
|
def initialize(data)
|
|
16
24
|
@data = data
|
|
17
|
-
|
|
25
|
+
dicts = data.dictionaries
|
|
26
|
+
@dictionary_matchers = dicts.ranked.map do |name, dictionary|
|
|
27
|
+
Matchers::Dictionary.new(name, dictionary, dicts.tries[name]).freeze
|
|
28
|
+
end.freeze
|
|
29
|
+
@matchers = build_matchers.each(&:freeze).freeze
|
|
18
30
|
end
|
|
19
31
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
# Returns all matches found in the password across every registered matcher.
|
|
33
|
+
#
|
|
34
|
+
# @param password [String] the password to analyse
|
|
35
|
+
# @param user_inputs [Array<String>] caller-supplied words to add as a dictionary
|
|
36
|
+
# @param reference_year [Integer] year used by the date matcher to pick the closest
|
|
37
|
+
# candidate; shared with the scorer so both phases agree even across midnight
|
|
38
|
+
# @return [Array<MatchBuilder>]
|
|
39
|
+
def matches(password, user_inputs = [], reference_year: Time.now.year)
|
|
40
|
+
user_dictionary =
|
|
41
|
+
user_inputs.any? && Matchers::Dictionary.new('user_inputs', DictionaryRanker.rank_dictionary(user_inputs))
|
|
42
|
+
matchers = @matchers + user_input_matchers(user_dictionary)
|
|
43
|
+
all_matches = matchers.flat_map do |matcher|
|
|
44
|
+
case matcher
|
|
45
|
+
when Matchers::Date
|
|
46
|
+
matcher.matches(password, reference_year:)
|
|
47
|
+
else
|
|
48
|
+
matcher.matches(password)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
all_matches + reverse_dictionary_matches(password, user_dictionary)
|
|
25
52
|
end
|
|
26
53
|
|
|
27
54
|
private
|
|
28
55
|
|
|
29
|
-
def user_input_matchers(
|
|
30
|
-
return [] unless
|
|
56
|
+
def user_input_matchers(user_dictionary)
|
|
57
|
+
return [] unless user_dictionary
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
dictionary_matcher = Matchers::Dictionary.new('user_inputs', user_ranked_dictionary)
|
|
34
|
-
l33t_matcher = Matchers::L33t.new([dictionary_matcher])
|
|
35
|
-
[dictionary_matcher, l33t_matcher]
|
|
59
|
+
[user_dictionary, Matchers::L33t.new([user_dictionary])]
|
|
36
60
|
end
|
|
37
61
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
# Run dictionary matchers on the reversed password and flip match positions
|
|
63
|
+
# back to the original password's coordinate space.
|
|
64
|
+
#
|
|
65
|
+
# This catches passwords like "drowssap" (reversed "password").
|
|
66
|
+
# Each returned match has reversed: true and its token restored to the
|
|
67
|
+
# original (un-reversed) form.
|
|
68
|
+
#
|
|
69
|
+
# @param password [String] the original password
|
|
70
|
+
# @param user_dictionary [Matchers::Dictionary, nil] pre-built user input matcher
|
|
71
|
+
# @return [Array<MatchBuilder>] dictionary matches found in the reversed password
|
|
72
|
+
def reverse_dictionary_matches(password, user_dictionary)
|
|
73
|
+
reversed = password.reverse
|
|
74
|
+
n = password.length
|
|
75
|
+
|
|
76
|
+
matchers = @dictionary_matchers
|
|
77
|
+
matchers += [user_dictionary] if user_dictionary
|
|
78
|
+
|
|
79
|
+
matches = []
|
|
80
|
+
matchers.each do |matcher|
|
|
81
|
+
matcher.matches(reversed).each do |match|
|
|
82
|
+
match.token = match.token.reverse
|
|
83
|
+
match.reversed = true
|
|
84
|
+
old_i = match.i
|
|
85
|
+
old_j = match.j
|
|
86
|
+
match.i = n - 1 - old_j
|
|
87
|
+
match.j = n - 1 - old_i
|
|
88
|
+
matches << match
|
|
89
|
+
end
|
|
43
90
|
end
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
91
|
+
matches
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_matchers
|
|
95
|
+
@dictionary_matchers + [
|
|
96
|
+
Matchers::L33t.new(@dictionary_matchers),
|
|
48
97
|
Matchers::Spatial.new(@data.adjacency_graphs),
|
|
49
98
|
Matchers::Digits.new,
|
|
50
99
|
Matchers::Repeat.new,
|
|
@@ -52,7 +101,6 @@ module Zxcvbn
|
|
|
52
101
|
Matchers::Year.new,
|
|
53
102
|
Matchers::Date.new
|
|
54
103
|
]
|
|
55
|
-
matchers
|
|
56
104
|
end
|
|
57
105
|
end
|
|
58
106
|
end
|
data/lib/zxcvbn/ruby.rb
ADDED
data/lib/zxcvbn/score.rb
CHANGED
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Zxcvbn
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
# The result of analysing a password — returned by {Zxcvbn.test}.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] password
|
|
7
|
+
# @return [String] the password that was evaluated
|
|
8
|
+
# @!attribute [r] guesses
|
|
9
|
+
# @return [Numeric] estimated number of guesses to crack the password
|
|
10
|
+
# @!attribute [r] sequence
|
|
11
|
+
# @return [Array<Match>] the optimal match sequence
|
|
12
|
+
# @!attribute [r] crack_times_seconds
|
|
13
|
+
# @return [Hash{String => Float}] crack time in seconds per attack scenario
|
|
14
|
+
# @!attribute [r] crack_times_display
|
|
15
|
+
# @return [Hash{String => String}] human-readable crack times per scenario
|
|
16
|
+
# @!attribute [r] score
|
|
17
|
+
# @return [Integer] 0–4 score (0 = very weak, 4 = very strong)
|
|
18
|
+
# @!attribute [r] calc_time
|
|
19
|
+
# @return [Float, nil] time taken to evaluate, in seconds
|
|
20
|
+
# @!attribute [r] feedback
|
|
21
|
+
# @return [Feedback, nil] human-readable feedback for low-scoring passwords
|
|
22
|
+
Score = ::Data.define(
|
|
23
|
+
:password, :guesses, :sequence, :crack_times_seconds,
|
|
24
|
+
:crack_times_display, :score, :calc_time, :feedback
|
|
25
|
+
) do
|
|
26
|
+
def initialize(calc_time: nil, feedback: nil, **kwargs)
|
|
27
|
+
super(calc_time:, feedback:, **kwargs)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [String] a human-readable representation omitting nil fields and password
|
|
31
|
+
def inspect
|
|
32
|
+
fields = to_h.reject { |k, v| v.nil? || k == :password }.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
|
|
33
|
+
"#<data #{self.class} #{fields}>"
|
|
34
|
+
end
|
|
7
35
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@crack_time_display = options[:crack_time_display]
|
|
12
|
-
@score = options[:score]
|
|
13
|
-
@match_sequence = options[:match_sequence]
|
|
14
|
-
@password = options[:password]
|
|
36
|
+
# @return [Float, nil] log10 of {#guesses}, or nil if guesses is not set
|
|
37
|
+
def guesses_log10
|
|
38
|
+
::Math.log10(guesses) if guesses
|
|
15
39
|
end
|
|
16
40
|
end
|
|
17
41
|
end
|
data/lib/zxcvbn/scorer.rb
CHANGED
|
@@ -1,104 +1,171 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'zxcvbn/
|
|
3
|
+
require 'zxcvbn/guesses'
|
|
4
4
|
require 'zxcvbn/crack_time'
|
|
5
5
|
require 'zxcvbn/score'
|
|
6
|
-
require 'zxcvbn/
|
|
6
|
+
require 'zxcvbn/match_builder'
|
|
7
7
|
|
|
8
8
|
module Zxcvbn
|
|
9
|
+
# Finds the match sequence that minimises the total number of guesses
|
|
10
|
+
# required to crack a password, using dynamic programming.
|
|
11
|
+
# @api private
|
|
9
12
|
class Scorer
|
|
10
|
-
|
|
13
|
+
include Guesses
|
|
14
|
+
include CrackTime
|
|
15
|
+
|
|
16
|
+
# Hash{Integer => Float} — factorial lookup for the DP hot loop.
|
|
17
|
+
# Keys must be non-negative integers. Precomputed to 170 (the last value
|
|
18
|
+
# before overflow); returns Float::MAX for any key > 170.
|
|
19
|
+
FACTORIAL = (0..170).each_with_object(Hash.new { Float::MAX }) do |n, h|
|
|
20
|
+
h[n] = n < 2 ? 1.0 : h[n - 1] * n
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
23
|
+
# Hash{Integer => Float} — powers of {MIN_GUESSES_BEFORE_GROWING_SEQUENCE} for
|
|
24
|
+
# the additive sequence-length penalty. Keys must be non-negative integers.
|
|
25
|
+
# Precomputed to 77 (the last value before overflow); returns Float::MAX for
|
|
26
|
+
# any key > 77.
|
|
27
|
+
MIN_GUESSES_POW = (0..77).each_with_object(Hash.new { Float::MAX }) do |n, h|
|
|
28
|
+
h[n] = MIN_GUESSES_BEFORE_GROWING_SEQUENCE.to_f**n
|
|
29
|
+
end.freeze
|
|
30
|
+
|
|
31
|
+
private_constant :FACTORIAL, :MIN_GUESSES_POW
|
|
32
|
+
|
|
33
|
+
# @param data [Data] the loaded frequency list and graph data
|
|
34
|
+
# @param omnimatch [Omnimatch] shared omnimatch instance
|
|
35
|
+
# @param reference_year [Integer] year used for date/year guess calculations
|
|
36
|
+
def initialize(data, omnimatch, reference_year)
|
|
11
37
|
@data = data
|
|
38
|
+
@omnimatch = omnimatch
|
|
39
|
+
@reference_year = reference_year
|
|
40
|
+
@repeat_cache = {}
|
|
12
41
|
end
|
|
13
42
|
|
|
14
43
|
attr_reader :data
|
|
15
44
|
|
|
16
|
-
|
|
17
|
-
|
|
45
|
+
# Find the match sequence that minimises total guesses for a password.
|
|
46
|
+
#
|
|
47
|
+
# Uses a DP over positions in the password. At each position k and sequence
|
|
48
|
+
# length l, the total cost is:
|
|
49
|
+
# factorial(l) * product_of_guesses + MIN_GUESSES^(l-1)
|
|
50
|
+
#
|
|
51
|
+
# The additive penalty discourages padding attacks where an adversary
|
|
52
|
+
# splits a password into many low-guesses submatches.
|
|
53
|
+
#
|
|
54
|
+
# @param password [String] the password to analyse
|
|
55
|
+
# @param matches [Array<MatchBuilder>] candidate matches from the matchers
|
|
56
|
+
# @param exclude_additive [Boolean] omit the sequence-length penalty
|
|
57
|
+
# (used when recursively analysing repeat base tokens)
|
|
58
|
+
# @return [Score] the optimal score with match sequence and guess count
|
|
59
|
+
def most_guessable_match_sequence(password, matches, exclude_additive: false)
|
|
60
|
+
n = password.length
|
|
61
|
+
|
|
62
|
+
return build_score(password, [], 1.0) if n.zero?
|
|
63
|
+
|
|
64
|
+
# index matches by their last character
|
|
65
|
+
matches_by_j = Array.new(n) { [] }
|
|
66
|
+
matches.each { |m| matches_by_j[m.j] << m }
|
|
67
|
+
matches_by_j.each { |arr| arr.sort_by!(&:i) }
|
|
68
|
+
|
|
69
|
+
# m[k][l] = best match for a sequence of l matches ending at position k
|
|
70
|
+
# pi_float[k][l] = cumulative guess product for those l matches as Float (avoids Bignum
|
|
71
|
+
# integer multiplication; each step is Integer × Float → Float)
|
|
72
|
+
# g[k][l] = total guesses (FACTORIAL[l] * pi_float + penalty) as Float
|
|
73
|
+
# g_log10[k][l] = log10(g[k][l]), used for dominance comparisons (small Float vs Float)
|
|
74
|
+
m = Array.new(n) { {} }
|
|
75
|
+
pi_float = Array.new(n) { {} }
|
|
76
|
+
g = Array.new(n) { {} }
|
|
77
|
+
g_log10 = Array.new(n) { {} }
|
|
78
|
+
|
|
79
|
+
update = lambda do |match, l|
|
|
80
|
+
j = match.j
|
|
81
|
+
est = estimate_guesses(match, password)
|
|
82
|
+
pi_prev = l > 1 ? pi_float[match.i - 1][l - 1] : 1.0
|
|
83
|
+
candidate = FACTORIAL[l] * est * pi_prev
|
|
84
|
+
candidate += MIN_GUESSES_POW[l - 1] unless exclude_additive
|
|
85
|
+
candidate_log10 = ::Math.log10(candidate)
|
|
86
|
+
# only improve if no sequence of length <= l ending at j already beats candidate
|
|
87
|
+
return if g_log10[j].any? { |u, a| u <= l && a <= candidate_log10 }
|
|
88
|
+
|
|
89
|
+
g[j][l] = candidate
|
|
90
|
+
g_log10[j][l] = candidate_log10
|
|
91
|
+
m[j][l] = match
|
|
92
|
+
pi_float[j][l] = est * pi_prev
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
make_bruteforce = lambda do |i, j|
|
|
96
|
+
MatchBuilder.new(pattern: 'bruteforce', i:, j:)
|
|
97
|
+
end
|
|
18
98
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
(0...password.length).each do |k|
|
|
26
|
-
# starting scenario to try and beat: adding a brute-force character to the minimum entropy sequence at k-1.
|
|
27
|
-
previous_k_entropy = k.positive? ? up_to_k[k - 1] : 0
|
|
28
|
-
up_to_k[k] = previous_k_entropy + lg(bruteforce_cardinality)
|
|
29
|
-
backpointers[k] = nil
|
|
30
|
-
matches.select do |match|
|
|
31
|
-
match.j == k
|
|
32
|
-
end.each do |match|
|
|
33
|
-
i = match.i
|
|
34
|
-
j = match.j
|
|
35
|
-
# see if best entropy up to i-1 + entropy of this match is less than the current minimum at j.
|
|
36
|
-
previous_i_entropy = i.positive? ? up_to_k[i - 1] : 0
|
|
37
|
-
candidate_entropy = previous_i_entropy + calc_entropy(match)
|
|
38
|
-
if up_to_k[j] && candidate_entropy < up_to_k[j]
|
|
39
|
-
up_to_k[j] = candidate_entropy
|
|
40
|
-
backpointers[j] = match
|
|
99
|
+
(0...n).each do |k|
|
|
100
|
+
matches_by_j[k].each do |match|
|
|
101
|
+
if match.i.positive?
|
|
102
|
+
m[match.i - 1].each_key { |l| update.call(match, l + 1) }
|
|
103
|
+
else
|
|
104
|
+
update.call(match, 1)
|
|
41
105
|
end
|
|
42
106
|
end
|
|
43
|
-
end
|
|
44
107
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
k = match.i - 1
|
|
53
|
-
else
|
|
54
|
-
k -= 1
|
|
108
|
+
# try bruteforce segments ending at k
|
|
109
|
+
update.call(make_bruteforce.call(0, k), 1)
|
|
110
|
+
(1..k).each do |t|
|
|
111
|
+
bf = make_bruteforce.call(t, k)
|
|
112
|
+
m[t - 1].each do |l, prev_match|
|
|
113
|
+
update.call(bf, l + 1) unless prev_match.pattern == 'bruteforce'
|
|
114
|
+
end
|
|
55
115
|
end
|
|
56
116
|
end
|
|
57
117
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
118
|
+
# find sequence length with minimum guesses at position n-1
|
|
119
|
+
optimal_l = g_log10[n - 1].min_by { |_, v| v }&.first
|
|
120
|
+
total_guesses = optimal_l ? g[n - 1][optimal_l] : 1
|
|
61
121
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
122
|
+
# backtrack to reconstruct sequence
|
|
123
|
+
sequence = []
|
|
124
|
+
k = n - 1
|
|
125
|
+
l = optimal_l
|
|
126
|
+
while k >= 0
|
|
127
|
+
match = m[k][l]
|
|
128
|
+
match.token ||= password.slice(match.i, match.j - match.i + 1)
|
|
129
|
+
sequence.unshift(match.build)
|
|
130
|
+
k = match.i - 1
|
|
131
|
+
l -= 1
|
|
132
|
+
end
|
|
65
133
|
|
|
66
|
-
|
|
67
|
-
Score.new(
|
|
68
|
-
password: password,
|
|
69
|
-
entropy: min_entropy.round(3),
|
|
70
|
-
match_sequence: match_sequence,
|
|
71
|
-
crack_time: crack_time.round(3),
|
|
72
|
-
crack_time_display: display_time(crack_time),
|
|
73
|
-
score: crack_time_to_score(crack_time)
|
|
74
|
-
)
|
|
134
|
+
build_score(password, sequence, total_guesses)
|
|
75
135
|
end
|
|
76
136
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
137
|
+
# Compute guesses for a repeat match by recursively scoring the base token.
|
|
138
|
+
#
|
|
139
|
+
# @param match [MatchBuilder] a repeat match with base_token set
|
|
140
|
+
# @return [Numeric] base_guesses * repeat_count
|
|
141
|
+
def repeat_guesses(match)
|
|
142
|
+
if match.base_guesses.nil?
|
|
143
|
+
# The same base_token can appear in multiple distinct match objects when
|
|
144
|
+
# a repeated token occurs at several positions in the password. Cache by
|
|
145
|
+
# string so each unique base_token is scored at most once per scoring run.
|
|
146
|
+
match.base_guesses = @repeat_cache[match.base_token] ||= begin
|
|
147
|
+
base_matches = @omnimatch.matches(match.base_token, reference_year: @reference_year)
|
|
148
|
+
most_guessable_match_sequence(match.base_token, base_matches).guesses
|
|
149
|
+
end
|
|
87
150
|
end
|
|
88
|
-
|
|
151
|
+
match.base_guesses * match.repeat_count
|
|
89
152
|
end
|
|
90
153
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# @param password [String]
|
|
157
|
+
# @param sequence [Array<Match>]
|
|
158
|
+
# @param guesses [Float]
|
|
159
|
+
# @return [Score]
|
|
160
|
+
def build_score(password, sequence, guesses)
|
|
161
|
+
attack_times = estimate_attack_times(guesses)
|
|
162
|
+
Score.new(
|
|
163
|
+
password:,
|
|
164
|
+
guesses:,
|
|
165
|
+
sequence: sequence.freeze,
|
|
166
|
+
crack_times_seconds: attack_times[:crack_times_seconds].freeze,
|
|
167
|
+
crack_times_display: attack_times[:crack_times_display].freeze,
|
|
168
|
+
score: guesses_to_score(guesses)
|
|
102
169
|
)
|
|
103
170
|
end
|
|
104
171
|
end
|
data/lib/zxcvbn/tester.rb
CHANGED
|
@@ -1,43 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'zxcvbn/
|
|
4
|
-
require 'zxcvbn/
|
|
3
|
+
require 'zxcvbn/clock'
|
|
4
|
+
require 'zxcvbn/feedback_giver'
|
|
5
|
+
require 'zxcvbn/omnimatch'
|
|
6
|
+
require 'zxcvbn/scorer'
|
|
5
7
|
|
|
6
8
|
module Zxcvbn
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
#
|
|
13
|
-
#
|
|
9
|
+
# Raised when a password exceeds the configured maximum length.
|
|
10
|
+
# Inherits from +ArgumentError+ for backward compatibility with existing
|
|
11
|
+
# +rescue ArgumentError+ clauses.
|
|
12
|
+
class PasswordTooLong < ArgumentError; end
|
|
13
|
+
|
|
14
|
+
# Evaluates password strength against dictionary lists, keyboard patterns,
|
|
15
|
+
# dates, sequences, and repeats. Construct via {Zxcvbn.tester_builder}:
|
|
14
16
|
#
|
|
15
|
-
# tester
|
|
17
|
+
# tester = Zxcvbn
|
|
18
|
+
# .tester_builder
|
|
19
|
+
# .add_word_list('company', %w[acme corp])
|
|
20
|
+
# .build
|
|
16
21
|
#
|
|
17
22
|
# tester.test("password 1")
|
|
18
23
|
# tester.test("password 2")
|
|
19
|
-
# tester.test("password 3")
|
|
20
24
|
class Tester
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
# @param data [Data] pre-configured data
|
|
26
|
+
# @param max_password_length [Integer] passwords longer than this raise
|
|
27
|
+
# {PasswordTooLong} from {#test}
|
|
28
|
+
# @raise [ArgumentError] if max_password_length is not a positive integer
|
|
29
|
+
def initialize(data:, max_password_length:)
|
|
30
|
+
unless max_password_length.is_a?(Integer) && max_password_length.positive?
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"max_password_length must be a positive integer; got #{max_password_length.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@data = data
|
|
36
|
+
@max_password_length = max_password_length
|
|
37
|
+
@omnimatch = Omnimatch.new(@data).freeze
|
|
23
38
|
end
|
|
24
39
|
|
|
40
|
+
attr_reader :max_password_length
|
|
41
|
+
|
|
42
|
+
# Evaluates a password and returns a {Score}.
|
|
43
|
+
#
|
|
44
|
+
# Raises {PasswordTooLong} if the password exceeds the +max_password_length+
|
|
45
|
+
# passed to {#initialize}. The limit exists because scoring time grows
|
|
46
|
+
# super-quadratically on adversarial inputs such as short repeated sequences
|
|
47
|
+
# (e.g. <tt>"ab" * 500</tt>).
|
|
48
|
+
#
|
|
49
|
+
# @param password [String] the password to evaluate
|
|
50
|
+
# @param user_inputs [Array<String>] caller-supplied words to treat as known
|
|
51
|
+
# @return [Score]
|
|
52
|
+
# @raise [PasswordTooLong] if the password exceeds the maximum length
|
|
25
53
|
def test(password, user_inputs = [])
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
password ||= ''
|
|
55
|
+
user_inputs = Array(user_inputs).select { |i| i.is_a?(String) }
|
|
56
|
+
if password.length > @max_password_length
|
|
57
|
+
raise PasswordTooLong, "Password exceeds the maximum length of #{@max_password_length}."
|
|
58
|
+
end
|
|
28
59
|
|
|
29
|
-
|
|
30
|
-
|
|
60
|
+
result = nil
|
|
61
|
+
calc_time = Clock.realtime do
|
|
62
|
+
reference_year = Time.now.year
|
|
63
|
+
scorer = Scorer.new(@data, @omnimatch, reference_year)
|
|
64
|
+
matches = @omnimatch.matches(password, user_inputs, reference_year:)
|
|
65
|
+
result = scorer.most_guessable_match_sequence(password, matches)
|
|
66
|
+
end
|
|
67
|
+
result.with(
|
|
68
|
+
calc_time:,
|
|
69
|
+
feedback: FeedbackGiver.get_feedback(result.score, result.sequence)
|
|
70
|
+
)
|
|
31
71
|
end
|
|
32
72
|
|
|
73
|
+
# @return [String] a concise representation that omits the large dictionary data
|
|
33
74
|
def inspect
|
|
34
75
|
"#<#{self.class}:0x#{__id__.to_s(16)}>"
|
|
35
76
|
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def sanitize(user_inputs)
|
|
40
|
-
user_inputs.select { |i| i.respond_to?(:downcase) }
|
|
41
|
-
end
|
|
42
77
|
end
|
|
43
78
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zxcvbn/data'
|
|
4
|
+
require 'zxcvbn/tester'
|
|
5
|
+
|
|
6
|
+
module Zxcvbn
|
|
7
|
+
# Fluent builder for constructing a {Tester} with custom word lists and options.
|
|
8
|
+
#
|
|
9
|
+
# Obtain a builder via {Zxcvbn.tester_builder} and call {#build} to get the {Tester}.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
#
|
|
13
|
+
# tester = Zxcvbn
|
|
14
|
+
# .tester_builder
|
|
15
|
+
# .add_word_list('company', %w[acme corp])
|
|
16
|
+
# .max_password_length(75)
|
|
17
|
+
# .build
|
|
18
|
+
class TesterBuilder
|
|
19
|
+
# Default maximum password length used when neither {#max_password_length} nor
|
|
20
|
+
# the +ZXCVBN_MAX_PASSWORD_LENGTH+ environment variable is set.
|
|
21
|
+
DEFAULT_MAX_PASSWORD_LENGTH = 256
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@word_lists = {}
|
|
25
|
+
@max_password_length = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param name [String] identifier for the word list; calling with the same name twice replaces the earlier list
|
|
29
|
+
# @param words [Array<String>, String] words to add; non-String elements are silently ignored during matching
|
|
30
|
+
# @return [self]
|
|
31
|
+
# @raise [ArgumentError] if name collides with a built-in dictionary name or +"user_inputs"+
|
|
32
|
+
def add_word_list(name, words)
|
|
33
|
+
if Data::RESERVED_NAMES.include?(name)
|
|
34
|
+
raise ArgumentError,
|
|
35
|
+
"#{name.inspect} is a reserved dictionary name; use a different name for custom word lists"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@word_lists[name] = Array(words)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param length [Integer] maximum password length for the built {Tester}
|
|
43
|
+
# @return [self]
|
|
44
|
+
# @raise [ArgumentError] if length is not a positive integer
|
|
45
|
+
def max_password_length(length)
|
|
46
|
+
unless length.is_a?(Integer) && length.positive?
|
|
47
|
+
raise ArgumentError, "max_password_length must be a positive integer; got #{length.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@max_password_length = length
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Tester]
|
|
55
|
+
# @raise [ArgumentError] if max_password_length or ZXCVBN_MAX_PASSWORD_LENGTH is not a positive integer
|
|
56
|
+
def build
|
|
57
|
+
data = Data.new
|
|
58
|
+
@word_lists.each_pair { |name, words| data.add_word_list(name, words) }
|
|
59
|
+
Tester.new(data: data.freeze, max_password_length: effective_max_password_length).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def effective_max_password_length
|
|
65
|
+
return @max_password_length if @max_password_length
|
|
66
|
+
|
|
67
|
+
env_str = ENV['ZXCVBN_MAX_PASSWORD_LENGTH']
|
|
68
|
+
return DEFAULT_MAX_PASSWORD_LENGTH unless env_str
|
|
69
|
+
|
|
70
|
+
value = begin
|
|
71
|
+
Integer(env_str, 10)
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
raise ArgumentError, "ZXCVBN_MAX_PASSWORD_LENGTH must be a positive integer; got #{env_str.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
unless value.positive?
|
|
77
|
+
raise ArgumentError, "ZXCVBN_MAX_PASSWORD_LENGTH must be a positive integer; got #{env_str.inspect}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/zxcvbn/trie.rb
CHANGED
|
@@ -5,7 +5,17 @@ module Zxcvbn
|
|
|
5
5
|
# Provides fast prefix-based lookups to eliminate unnecessary substring checks.
|
|
6
6
|
#
|
|
7
7
|
# @see https://en.wikipedia.org/wiki/Trie
|
|
8
|
+
# @api private
|
|
8
9
|
class Trie
|
|
10
|
+
# Build a trie from a ranked dictionary hash.
|
|
11
|
+
# @param ranked_dictionary [Hash{String => Integer}] word → rank
|
|
12
|
+
# @return [Trie]
|
|
13
|
+
def self.from_ranked(ranked_dictionary)
|
|
14
|
+
trie = new
|
|
15
|
+
ranked_dictionary.each { |word, rank| trie.insert(word, rank) }
|
|
16
|
+
trie
|
|
17
|
+
end
|
|
18
|
+
|
|
9
19
|
def initialize
|
|
10
20
|
@root = {}
|
|
11
21
|
end
|
|
@@ -40,5 +50,16 @@ module Zxcvbn
|
|
|
40
50
|
|
|
41
51
|
results
|
|
42
52
|
end
|
|
53
|
+
|
|
54
|
+
def inspect = "#<#{self.class}:0x#{__id__.to_s(16)}>"
|
|
55
|
+
|
|
56
|
+
def freeze
|
|
57
|
+
stack = [@root]
|
|
58
|
+
while (node = stack.pop)
|
|
59
|
+
node.each_value { |v| stack.push(v) if v.is_a?(Hash) }
|
|
60
|
+
node.freeze
|
|
61
|
+
end
|
|
62
|
+
super
|
|
63
|
+
end
|
|
43
64
|
end
|
|
44
65
|
end
|
data/lib/zxcvbn/version.rb
CHANGED