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/clock.rb
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Zxcvbn
|
|
4
|
+
# Monotonic-clock utility for measuring elapsed time.
|
|
5
|
+
# @api private
|
|
4
6
|
module Clock
|
|
7
|
+
# Yields to the block and returns the elapsed time in seconds.
|
|
8
|
+
#
|
|
9
|
+
# @yield block whose execution time is measured
|
|
10
|
+
# @return [Float] elapsed seconds as a monotonic-clock duration
|
|
5
11
|
def self.realtime
|
|
6
12
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
7
13
|
yield
|
data/lib/zxcvbn/crack_time.rb
CHANGED
|
@@ -1,30 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Zxcvbn
|
|
4
|
+
# Mixin that converts a guess count into estimated crack times and scores.
|
|
5
|
+
#
|
|
6
|
+
# Provides {estimate_attack_times}, {guesses_to_score}, and {display_time}
|
|
7
|
+
# mirroring the crack-time logic from zxcvbn.js v4.
|
|
8
|
+
# @api private
|
|
4
9
|
module CrackTime
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
ATTACK_SCENARIOS = {
|
|
11
|
+
'online_throttling_100_per_hour' => 100.0 / 3600,
|
|
12
|
+
'online_no_throttling_10_per_second' => 10.0,
|
|
13
|
+
'offline_slow_hashing_1e4_per_second' => 1e4,
|
|
14
|
+
'offline_fast_hashing_1e10_per_second' => 1e10
|
|
15
|
+
}.freeze
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
# Returns the estimated seconds and display strings for each attack scenario.
|
|
18
|
+
#
|
|
19
|
+
# @param guesses [Numeric] estimated guess count
|
|
20
|
+
# @return [Hash] with :crack_times_seconds and :crack_times_display
|
|
21
|
+
def estimate_attack_times(guesses)
|
|
22
|
+
seconds = ATTACK_SCENARIOS.transform_values { |rate| [guesses / rate, Float::MAX].min }
|
|
23
|
+
display = seconds.transform_values { |s| display_time(s) }
|
|
24
|
+
{ crack_times_seconds: seconds, crack_times_display: display }
|
|
12
25
|
end
|
|
13
26
|
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
# Convert a guess count to a 0–4 score using zxcvbn.js v4 thresholds.
|
|
28
|
+
#
|
|
29
|
+
# A small delta (5) is added to each threshold so that passwords just at
|
|
30
|
+
# a boundary are not bumped up to the next score band by floating-point
|
|
31
|
+
# noise in the guess count.
|
|
32
|
+
#
|
|
33
|
+
# @param guesses [Numeric] estimated number of guesses to crack the password
|
|
34
|
+
# @return [Integer] score in the range 0..4
|
|
35
|
+
def guesses_to_score(guesses)
|
|
36
|
+
delta = 5
|
|
37
|
+
if guesses < 1_000 + delta
|
|
16
38
|
0
|
|
17
|
-
elsif
|
|
39
|
+
elsif guesses < 1_000_000 + delta
|
|
18
40
|
1
|
|
19
|
-
elsif
|
|
41
|
+
elsif guesses < 100_000_000 + delta
|
|
20
42
|
2
|
|
21
|
-
elsif
|
|
43
|
+
elsif guesses < 10_000_000_000 + delta
|
|
22
44
|
3
|
|
23
45
|
else
|
|
24
46
|
4
|
|
25
47
|
end
|
|
26
48
|
end
|
|
27
49
|
|
|
50
|
+
# Convert a number of seconds into a human-readable display string.
|
|
51
|
+
#
|
|
52
|
+
# @param seconds [Numeric] duration in seconds
|
|
53
|
+
# @return [String] e.g. "instant", "3 minutes", "centuries"
|
|
28
54
|
def display_time(seconds)
|
|
29
55
|
minute = 60
|
|
30
56
|
hour = minute * 60
|
|
@@ -33,18 +59,26 @@ module Zxcvbn
|
|
|
33
59
|
year = month * 12
|
|
34
60
|
century = year * 100
|
|
35
61
|
|
|
36
|
-
if seconds <
|
|
37
|
-
'
|
|
62
|
+
if seconds < 1
|
|
63
|
+
'less than a second'
|
|
64
|
+
elsif seconds < minute
|
|
65
|
+
t = seconds.round
|
|
66
|
+
"#{t} second#{'s' unless t == 1}"
|
|
38
67
|
elsif seconds < hour
|
|
39
|
-
|
|
68
|
+
t = (seconds / minute).round
|
|
69
|
+
"#{t} minute#{'s' unless t == 1}"
|
|
40
70
|
elsif seconds < day
|
|
41
|
-
|
|
71
|
+
t = (seconds / hour).round
|
|
72
|
+
"#{t} hour#{'s' unless t == 1}"
|
|
42
73
|
elsif seconds < month
|
|
43
|
-
|
|
74
|
+
t = (seconds / day).round
|
|
75
|
+
"#{t} day#{'s' unless t == 1}"
|
|
44
76
|
elsif seconds < year
|
|
45
|
-
|
|
77
|
+
t = (seconds / month).round
|
|
78
|
+
"#{t} month#{'s' unless t == 1}"
|
|
46
79
|
elsif seconds < century
|
|
47
|
-
|
|
80
|
+
t = (seconds / year).round
|
|
81
|
+
"#{t} year#{'s' unless t == 1}"
|
|
48
82
|
else
|
|
49
83
|
'centuries'
|
|
50
84
|
end
|
data/lib/zxcvbn/data.rb
CHANGED
|
@@ -1,30 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'pathname'
|
|
4
5
|
require 'zxcvbn/dictionary_ranker'
|
|
5
6
|
require 'zxcvbn/trie'
|
|
6
7
|
|
|
7
8
|
module Zxcvbn
|
|
9
|
+
# Holds all loaded frequency lists, adjacency graphs, tries, and graph stats
|
|
10
|
+
# used by the matchers and scorer.
|
|
11
|
+
#
|
|
12
|
+
# @attr_reader adjacency_graphs [Hash{String => Hash}] keyboard adjacency data
|
|
13
|
+
# @attr_reader graph_stats [Hash{String => Hash}] precomputed average degree and key count
|
|
14
|
+
# @attr_reader dictionaries [#ranked, #tries] consistent snapshot of ranked dictionaries and
|
|
15
|
+
# their tries; use this for concurrent reads to guarantee the pair is always in sync
|
|
16
|
+
# @api private
|
|
8
17
|
class Data
|
|
18
|
+
DATA_PATH = Pathname(File.expand_path('../../data', __dir__))
|
|
19
|
+
private_constant :DATA_PATH
|
|
20
|
+
|
|
21
|
+
# Consistent named pair of ranked dictionaries and their tries.
|
|
22
|
+
Dictionaries = ::Data.define(:ranked, :tries) do
|
|
23
|
+
def inspect = "#<#{self.class}:0x#{__id__.to_s(16)}>"
|
|
24
|
+
end
|
|
25
|
+
private_constant :Dictionaries
|
|
26
|
+
|
|
27
|
+
# Built-in dictionary names and the reserved per-call key.
|
|
28
|
+
# These names cannot be passed to {TesterBuilder#add_word_list}.
|
|
29
|
+
RESERVED_NAMES = %w[
|
|
30
|
+
english_wikipedia female_names male_names passwords surnames us_tv_and_film user_inputs
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Loads all built-in frequency lists and adjacency graphs from disk.
|
|
9
34
|
def initialize
|
|
10
|
-
|
|
11
|
-
'
|
|
35
|
+
ranked = DictionaryRanker.rank_dictionaries(
|
|
36
|
+
'english_wikipedia' => read_word_list('english_wikipedia.txt'),
|
|
12
37
|
'female_names' => read_word_list('female_names.txt'),
|
|
13
38
|
'male_names' => read_word_list('male_names.txt'),
|
|
14
39
|
'passwords' => read_word_list('passwords.txt'),
|
|
15
|
-
'surnames' => read_word_list('surnames.txt')
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@
|
|
40
|
+
'surnames' => read_word_list('surnames.txt'),
|
|
41
|
+
'us_tv_and_film' => read_word_list('us_tv_and_film.txt')
|
|
42
|
+
).tap { |r| r.each_value(&:freeze) }.freeze
|
|
43
|
+
tries = build_tries(ranked).tap { |t| t.each_value(&:freeze) }.freeze
|
|
44
|
+
@dictionaries = Dictionaries.new(ranked:, tries:)
|
|
45
|
+
@adjacency_graphs =
|
|
46
|
+
JSON.parse(DATA_PATH.join('adjacency_graphs.json').read)
|
|
47
|
+
.tap { |gs| gs.each_value { |g| g.each_value { |a| a.each(&:freeze).freeze }.freeze } }
|
|
48
|
+
.freeze
|
|
49
|
+
@graph_stats = compute_graph_stats.each_value(&:freeze).freeze
|
|
20
50
|
end
|
|
21
51
|
|
|
22
|
-
attr_reader :
|
|
52
|
+
attr_reader :adjacency_graphs, :graph_stats, :dictionaries
|
|
53
|
+
|
|
54
|
+
def inspect = "#<#{self.class}:0x#{__id__.to_s(16)}>"
|
|
23
55
|
|
|
56
|
+
# @return [Hash{String => Hash{String => Integer}}] word → rank maps
|
|
57
|
+
def ranked_dictionaries = @dictionaries.ranked
|
|
58
|
+
|
|
59
|
+
# @return [Hash{String => Trie}] prefix tries per dictionary
|
|
60
|
+
def dictionary_tries = @dictionaries.tries
|
|
61
|
+
|
|
62
|
+
# Adds a custom word list and builds a trie for it.
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] dictionary name (used as a key in {#ranked_dictionaries})
|
|
65
|
+
# @param list [Array<String>] ordered words (most common first)
|
|
66
|
+
# @return [void]
|
|
24
67
|
def add_word_list(name, list)
|
|
25
|
-
ranked_dict = DictionaryRanker.rank_dictionary(list)
|
|
26
|
-
|
|
27
|
-
@
|
|
68
|
+
ranked_dict = DictionaryRanker.rank_dictionary(list.select { |w| w.is_a?(String) }).freeze
|
|
69
|
+
trie = Trie.from_ranked(ranked_dict).freeze
|
|
70
|
+
@dictionaries = @dictionaries.with(
|
|
71
|
+
ranked: @dictionaries.ranked.merge(name => ranked_dict).freeze,
|
|
72
|
+
tries: @dictionaries.tries.merge(name => trie).freeze
|
|
73
|
+
)
|
|
28
74
|
end
|
|
29
75
|
|
|
30
76
|
private
|
|
@@ -33,14 +79,8 @@ module Zxcvbn
|
|
|
33
79
|
DATA_PATH.join('frequency_lists', file).read.split
|
|
34
80
|
end
|
|
35
81
|
|
|
36
|
-
def build_tries
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def build_trie(ranked_dictionary)
|
|
41
|
-
trie = Trie.new
|
|
42
|
-
ranked_dictionary.each { |word, rank| trie.insert(word, rank) }
|
|
43
|
-
trie
|
|
82
|
+
def build_tries(ranked)
|
|
83
|
+
ranked.transform_values { |dict| Trie.from_ranked(dict) }
|
|
44
84
|
end
|
|
45
85
|
|
|
46
86
|
def compute_graph_stats
|
|
@@ -52,8 +92,8 @@ module Zxcvbn
|
|
|
52
92
|
starting_positions = graph.length
|
|
53
93
|
|
|
54
94
|
stats[graph_name] = {
|
|
55
|
-
average_degree
|
|
56
|
-
starting_positions:
|
|
95
|
+
average_degree:,
|
|
96
|
+
starting_positions:
|
|
57
97
|
}
|
|
58
98
|
end
|
|
59
99
|
stats
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Zxcvbn
|
|
4
|
+
# Converts raw word lists into frequency-ranked dictionaries for matcher use.
|
|
5
|
+
# @api private
|
|
4
6
|
class DictionaryRanker
|
|
7
|
+
# Ranks multiple word lists, returning a hash of ranked dictionaries.
|
|
8
|
+
#
|
|
9
|
+
# @param lists [Hash{Symbol => Array<String>}] named word lists
|
|
10
|
+
# @return [Hash{Symbol => Hash{String => Integer}}] lowercased word → rank mappings
|
|
5
11
|
def self.rank_dictionaries(lists)
|
|
6
12
|
lists.transform_values do |words|
|
|
7
13
|
rank_dictionary(words)
|
|
8
14
|
end
|
|
9
15
|
end
|
|
10
16
|
|
|
17
|
+
# Ranks a single word list; rank starts at 1 (most common).
|
|
18
|
+
#
|
|
19
|
+
# @param words [Array<String>] ordered words (most common first)
|
|
20
|
+
# @return [Hash{String => Integer}] lowercased word → 1-based rank
|
|
11
21
|
def self.rank_dictionary(words)
|
|
12
22
|
words
|
|
13
23
|
.each_with_index
|
data/lib/zxcvbn/feedback.rb
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Zxcvbn
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
# Human-readable feedback for a low-scoring password.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] warning
|
|
7
|
+
# @return [String] a single warning message, or empty string
|
|
8
|
+
# @!attribute [r] suggestions
|
|
9
|
+
# @return [Array<String>] ordered list of improvement tips
|
|
10
|
+
Feedback = ::Data.define(:warning, :suggestions) do
|
|
11
|
+
# @param warning [String] warning message (default: empty string)
|
|
12
|
+
# @param suggestions [Array<String>] improvement tips (default: [])
|
|
13
|
+
def initialize(warning: nil, suggestions: [])
|
|
14
|
+
super(warning: warning || '', suggestions: suggestions.freeze)
|
|
10
15
|
end
|
|
11
16
|
end
|
|
12
17
|
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'zxcvbn/
|
|
3
|
+
require 'zxcvbn/guesses'
|
|
4
4
|
require 'zxcvbn/feedback'
|
|
5
5
|
|
|
6
6
|
module Zxcvbn
|
|
7
|
+
# Generates human-readable {Feedback} for a password given its score and match sequence.
|
|
8
|
+
# @api private
|
|
7
9
|
class FeedbackGiver
|
|
8
10
|
NAME_DICTIONARIES = %w[surnames male_names female_names].freeze
|
|
9
11
|
|
|
@@ -12,10 +14,15 @@ module Zxcvbn
|
|
|
12
14
|
'Use a few words, avoid common phrases',
|
|
13
15
|
'No need for symbols, digits, or uppercase letters'
|
|
14
16
|
]
|
|
15
|
-
)
|
|
17
|
+
)
|
|
16
18
|
|
|
17
|
-
EMPTY_FEEDBACK = Feedback.new
|
|
19
|
+
EMPTY_FEEDBACK = Feedback.new
|
|
18
20
|
|
|
21
|
+
# Returns feedback appropriate for the given score and match sequence.
|
|
22
|
+
#
|
|
23
|
+
# @param score [Integer] 0–4 score from the scorer
|
|
24
|
+
# @param sequence [Array<Match>] optimal match sequence
|
|
25
|
+
# @return [Feedback]
|
|
19
26
|
def self.get_feedback(score, sequence)
|
|
20
27
|
# starting feedback
|
|
21
28
|
return DEFAULT_FEEDBACK if sequence.empty?
|
|
@@ -25,7 +32,7 @@ module Zxcvbn
|
|
|
25
32
|
|
|
26
33
|
# tie feedback to the longest match for longer sequences
|
|
27
34
|
longest_match = sequence[0]
|
|
28
|
-
sequence[1
|
|
35
|
+
sequence[1..].each do |match|
|
|
29
36
|
longest_match = match if match.token.length > longest_match.token.length
|
|
30
37
|
end
|
|
31
38
|
|
|
@@ -33,36 +40,46 @@ module Zxcvbn
|
|
|
33
40
|
extra_feedback = 'Add another word or two. Uncommon words are better.'
|
|
34
41
|
|
|
35
42
|
if feedback.nil?
|
|
36
|
-
|
|
43
|
+
Feedback.new(suggestions: [extra_feedback])
|
|
37
44
|
else
|
|
38
|
-
feedback.suggestions
|
|
45
|
+
feedback.with(suggestions: [extra_feedback, *feedback.suggestions])
|
|
39
46
|
end
|
|
40
|
-
|
|
41
|
-
feedback
|
|
42
47
|
end
|
|
43
48
|
|
|
49
|
+
# Returns pattern-specific feedback for a single match, or nil if none applies.
|
|
50
|
+
#
|
|
51
|
+
# @param match [Match]
|
|
52
|
+
# @param is_sole_match [Boolean] true when this is the only match in the sequence
|
|
53
|
+
# @return [Feedback, nil]
|
|
44
54
|
def self.get_match_feedback(match, is_sole_match)
|
|
45
55
|
case match.pattern
|
|
46
56
|
when 'dictionary'
|
|
47
57
|
get_dictionary_match_feedback match, is_sole_match
|
|
48
58
|
|
|
49
59
|
when 'spatial'
|
|
50
|
-
warning =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
warning =
|
|
61
|
+
if match.turns == 1
|
|
62
|
+
'Straight rows of keys are easy to guess'
|
|
63
|
+
else
|
|
64
|
+
'Short keyboard patterns are easy to guess'
|
|
65
|
+
end
|
|
55
66
|
|
|
56
67
|
Feedback.new(
|
|
57
|
-
warning
|
|
68
|
+
warning:,
|
|
58
69
|
suggestions: [
|
|
59
70
|
'Use a longer keyboard pattern with more turns'
|
|
60
71
|
]
|
|
61
72
|
)
|
|
62
73
|
|
|
63
74
|
when 'repeat'
|
|
75
|
+
warning =
|
|
76
|
+
if match.base_token.length == 1
|
|
77
|
+
'Repeats like "aaa" are easy to guess'
|
|
78
|
+
else
|
|
79
|
+
'Repeats like "abcabcabc" are only slightly harder to guess than "abc"'
|
|
80
|
+
end
|
|
64
81
|
Feedback.new(
|
|
65
|
-
warning
|
|
82
|
+
warning:,
|
|
66
83
|
suggestions: [
|
|
67
84
|
'Avoid repeated words and characters'
|
|
68
85
|
]
|
|
@@ -76,6 +93,15 @@ module Zxcvbn
|
|
|
76
93
|
]
|
|
77
94
|
)
|
|
78
95
|
|
|
96
|
+
when 'year'
|
|
97
|
+
Feedback.new(
|
|
98
|
+
warning: 'Recent years are easy to guess',
|
|
99
|
+
suggestions: [
|
|
100
|
+
'Avoid recent years',
|
|
101
|
+
'Avoid years that are associated with you'
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
79
105
|
when 'date'
|
|
80
106
|
Feedback.new(
|
|
81
107
|
warning: 'Dates are often easy to guess',
|
|
@@ -86,49 +112,48 @@ module Zxcvbn
|
|
|
86
112
|
end
|
|
87
113
|
end
|
|
88
114
|
|
|
115
|
+
# Returns feedback specific to a dictionary match.
|
|
116
|
+
#
|
|
117
|
+
# @param match [Match] a dictionary pattern match
|
|
118
|
+
# @param is_sole_match [Boolean] true when this is the only match in the sequence
|
|
119
|
+
# @return [Feedback]
|
|
89
120
|
def self.get_dictionary_match_feedback(match, is_sole_match)
|
|
90
|
-
warning =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
warning =
|
|
122
|
+
if match.dictionary_name == 'passwords'
|
|
123
|
+
if is_sole_match && !match.l33t && !match.reversed
|
|
124
|
+
if match.rank <= 10
|
|
125
|
+
'This is a top-10 common password'
|
|
126
|
+
elsif match.rank <= 100
|
|
127
|
+
'This is a top-100 common password'
|
|
128
|
+
else
|
|
129
|
+
'This is a very common password'
|
|
130
|
+
end
|
|
131
|
+
elsif (match.guesses_log10 || 0) <= 4
|
|
132
|
+
'This is similar to a commonly used password'
|
|
133
|
+
end
|
|
134
|
+
elsif match.dictionary_name == 'english_wikipedia'
|
|
135
|
+
'A word by itself is easy to guess' if is_sole_match
|
|
136
|
+
elsif NAME_DICTIONARIES.include? match.dictionary_name
|
|
137
|
+
if is_sole_match
|
|
138
|
+
'Names and surnames by themselves are easy to guess'
|
|
139
|
+
else
|
|
140
|
+
'Common names and surnames are easy to guess'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
109
143
|
|
|
110
144
|
suggestions = []
|
|
111
145
|
word = match.token
|
|
112
146
|
|
|
113
|
-
if word =~ Zxcvbn::
|
|
114
|
-
suggestions.push
|
|
115
|
-
elsif word =~ Zxcvbn::
|
|
116
|
-
suggestions.push(
|
|
117
|
-
'All-uppercase is almost as easy to guess as all-lowercase'
|
|
118
|
-
)
|
|
147
|
+
if word =~ Zxcvbn::Guesses::START_UPPER
|
|
148
|
+
suggestions.push("Capitalization doesn't help very much")
|
|
149
|
+
elsif word =~ Zxcvbn::Guesses::ALL_UPPER && word.downcase != word
|
|
150
|
+
suggestions.push('All-uppercase is almost as easy to guess as all-lowercase')
|
|
119
151
|
end
|
|
120
152
|
|
|
121
|
-
if match.
|
|
122
|
-
|
|
123
|
-
"Predictable substitutions like '@' instead of 'a' \
|
|
124
|
-
don't help very much"
|
|
125
|
-
)
|
|
126
|
-
end
|
|
153
|
+
suggestions.push("Reversed words aren't much harder to guess") if match.reversed && match.token.length >= 4
|
|
154
|
+
suggestions.push("Predictable substitutions like '@' instead of 'a' don't help very much") if match.l33t
|
|
127
155
|
|
|
128
|
-
Feedback.new(
|
|
129
|
-
warning: warning,
|
|
130
|
-
suggestions: suggestions
|
|
131
|
-
)
|
|
156
|
+
Feedback.new(warning:, suggestions:)
|
|
132
157
|
end
|
|
133
158
|
end
|
|
134
159
|
end
|