zxcvbn-ruby 1.3.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/README.md +322 -75
  4. data/data/frequency_lists/english_wikipedia.txt +30000 -0
  5. data/data/frequency_lists/female_names.txt +11 -114
  6. data/data/frequency_lists/male_names.txt +3 -24
  7. data/data/frequency_lists/passwords.txt +29623 -6764
  8. data/data/frequency_lists/surnames.txt +28 -30611
  9. data/data/frequency_lists/{english.txt → us_tv_and_film.txt} +147 -13532
  10. data/lib/zxcvbn/clock.rb +6 -0
  11. data/lib/zxcvbn/crack_time.rb +52 -18
  12. data/lib/zxcvbn/data.rb +61 -21
  13. data/lib/zxcvbn/dictionary_ranker.rb +10 -0
  14. data/lib/zxcvbn/feedback.rb +11 -6
  15. data/lib/zxcvbn/feedback_giver.rb +75 -50
  16. data/lib/zxcvbn/guesses.rb +208 -0
  17. data/lib/zxcvbn/match.rb +95 -15
  18. data/lib/zxcvbn/match_builder.rb +15 -0
  19. data/lib/zxcvbn/matchers/date.rb +171 -106
  20. data/lib/zxcvbn/matchers/dictionary.rb +15 -8
  21. data/lib/zxcvbn/matchers/digits.rb +6 -1
  22. data/lib/zxcvbn/matchers/l33t.rb +30 -34
  23. data/lib/zxcvbn/matchers/regex_helpers.rb +14 -6
  24. data/lib/zxcvbn/matchers/repeat.rb +47 -16
  25. data/lib/zxcvbn/matchers/sequences.rb +58 -48
  26. data/lib/zxcvbn/matchers/spatial.rb +22 -6
  27. data/lib/zxcvbn/matchers/year.rb +6 -1
  28. data/lib/zxcvbn/math.rb +15 -28
  29. data/lib/zxcvbn/omnimatch.rb +70 -22
  30. data/lib/zxcvbn/ruby.rb +3 -0
  31. data/lib/zxcvbn/score.rb +34 -10
  32. data/lib/zxcvbn/scorer.rb +142 -75
  33. data/lib/zxcvbn/tester.rb +58 -23
  34. data/lib/zxcvbn/tester_builder.rb +83 -0
  35. data/lib/zxcvbn/trie.rb +21 -0
  36. data/lib/zxcvbn/version.rb +1 -1
  37. data/lib/zxcvbn.rb +47 -7
  38. data/sig/README.md +65 -0
  39. data/sig/zxcvbn/clock.rbs +5 -0
  40. data/sig/zxcvbn/crack_time.rbs +11 -0
  41. data/sig/zxcvbn/data.rbs +40 -0
  42. data/sig/zxcvbn/dictionary_ranker.rbs +7 -0
  43. data/sig/zxcvbn/feedback.rbs +10 -0
  44. data/sig/zxcvbn/feedback_giver.rbs +13 -0
  45. data/sig/zxcvbn/guesses.rbs +36 -0
  46. data/sig/zxcvbn/match.rbs +40 -0
  47. data/sig/zxcvbn/match_builder.rbs +36 -0
  48. data/sig/zxcvbn/matchers/date.rbs +23 -0
  49. data/sig/zxcvbn/matchers/dictionary.rbs +21 -0
  50. data/sig/zxcvbn/matchers/digits.rbs +11 -0
  51. data/sig/zxcvbn/matchers/l33t.rbs +27 -0
  52. data/sig/zxcvbn/matchers/regex_helpers.rbs +7 -0
  53. data/sig/zxcvbn/matchers/repeat.rbs +11 -0
  54. data/sig/zxcvbn/matchers/sequences.rbs +16 -0
  55. data/sig/zxcvbn/matchers/spatial.rbs +15 -0
  56. data/sig/zxcvbn/matchers/year.rbs +11 -0
  57. data/sig/zxcvbn/math.rbs +9 -0
  58. data/sig/zxcvbn/omnimatch.rbs +19 -0
  59. data/sig/zxcvbn/score.rbs +26 -0
  60. data/sig/zxcvbn/scorer.rbs +19 -0
  61. data/sig/zxcvbn/tester.rbs +15 -0
  62. data/sig/zxcvbn/tester_builder.rbs +16 -0
  63. data/sig/zxcvbn/trie.rbs +17 -0
  64. data/sig/zxcvbn.rbs +12 -0
  65. metadata +46 -12
  66. data/lib/zxcvbn/entropy.rb +0 -158
  67. data/lib/zxcvbn/matchers/new_l33t.rb +0 -118
  68. data/lib/zxcvbn/password_strength.rb +0 -27
@@ -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
- @matchers = build_matchers
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
- def matches(password, user_inputs = [])
21
- matchers = @matchers + user_input_matchers(user_inputs)
22
- matchers.map do |matcher|
23
- matcher.matches(password)
24
- end.inject(&:+)
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(user_inputs)
30
- return [] unless user_inputs.any?
56
+ def user_input_matchers(user_dictionary)
57
+ return [] unless user_dictionary
31
58
 
32
- user_ranked_dictionary = DictionaryRanker.rank_dictionary(user_inputs)
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
- def build_matchers
39
- matchers = []
40
- dictionary_matchers = @data.ranked_dictionaries.map do |name, dictionary|
41
- trie = @data.dictionary_tries[name]
42
- Matchers::Dictionary.new(name, dictionary, trie)
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
- l33t_matcher = Matchers::L33t.new(dictionary_matchers)
45
- matchers += dictionary_matchers
46
- matchers += [
47
- l33t_matcher,
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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zxcvbn'
data/lib/zxcvbn/score.rb CHANGED
@@ -1,17 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
- class Score
5
- attr_accessor :entropy, :crack_time, :crack_time_display, :score, :pattern,
6
- :match_sequence, :password, :calc_time, :feedback
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
- def initialize(options = {})
9
- @entropy = options[:entropy]
10
- @crack_time = options[:crack_time]
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/entropy'
3
+ require 'zxcvbn/guesses'
4
4
  require 'zxcvbn/crack_time'
5
5
  require 'zxcvbn/score'
6
- require 'zxcvbn/match'
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
- def initialize(data)
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
- include Entropy
17
- include CrackTime
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
- def minimum_entropy_match_sequence(password, matches)
20
- bruteforce_cardinality = bruteforce_cardinality(password) # e.g. 26 for lowercase
21
- up_to_k = [] # minimum entropy up to k.
22
- # for the optimal sequence of matches up to k, holds the final match (match.j == k).
23
- # null means the sequence ends w/ a brute-force character.
24
- backpointers = []
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
- # walk backwards and decode the best sequence
46
- match_sequence = []
47
- k = password.length - 1
48
- while k >= 0
49
- match = backpointers[k]
50
- if match
51
- match_sequence.unshift match
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
- match_sequence = pad_with_bruteforce_matches(match_sequence, password, bruteforce_cardinality)
59
- score_for(password, match_sequence, up_to_k)
60
- end
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
- def score_for(password, match_sequence, up_to_k)
63
- min_entropy = up_to_k[password.length - 1] || 0 # or 0 corner case is for an empty password ''
64
- crack_time = entropy_to_crack_time(min_entropy)
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
- # final result object
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
- def pad_with_bruteforce_matches(match_sequence, password, bruteforce_cardinality)
78
- k = 0
79
- match_sequence_copy = []
80
- match_sequence.each do |match|
81
- match_sequence_copy << make_bruteforce_match(password, k, match.i - 1, bruteforce_cardinality) if match.i > k
82
- k = match.j + 1
83
- match_sequence_copy << match
84
- end
85
- if k < password.length
86
- match_sequence_copy << make_bruteforce_match(password, k, password.length - 1, bruteforce_cardinality)
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
- match_sequence_copy
151
+ match.base_guesses * match.repeat_count
89
152
  end
90
153
 
91
- # fill in the blanks between pattern matches with bruteforce "matches"
92
- # that way the match sequence fully covers the password:
93
- # match1.j == match2.i - 1 for every adjacent match1, match2.
94
- def make_bruteforce_match(password, i, j, bruteforce_cardinality)
95
- Match.new(
96
- pattern: 'bruteforce',
97
- i: i,
98
- j: j,
99
- token: password.slice(i, j - i + 1),
100
- entropy: lg(bruteforce_cardinality**(j - i + 1)),
101
- cardinality: bruteforce_cardinality
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/data'
4
- require 'zxcvbn/password_strength'
3
+ require 'zxcvbn/clock'
4
+ require 'zxcvbn/feedback_giver'
5
+ require 'zxcvbn/omnimatch'
6
+ require 'zxcvbn/scorer'
5
7
 
6
8
  module Zxcvbn
7
- # Allows you to test the strength of multiple passwords without reading and
8
- # parsing the dictionary data from disk each test. Dictionary data is read
9
- # once from disk and stored in memory for the life of the Tester object.
10
- #
11
- # Example:
12
- #
13
- # tester = Zxcvbn::Tester.new
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.add_word_lists("custom" => ["words"])
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
- def initialize
22
- @data = Data.new
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
- PasswordStrength.new(@data).test(password, sanitize(user_inputs))
27
- end
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
- def add_word_lists(lists)
30
- lists.each_pair { |name, words| @data.add_word_list(name, sanitize(words)) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
- VERSION = '1.3.0'
4
+ VERSION = '2.0.0'
5
5
  end