zxcvbn 0.1.1 → 0.1.2
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/Gemfile +9 -4
- data/Gemfile.lock +26 -1
- data/bin/console +5 -2
- data/lib/zxcvbn.rb +3 -3
- data/lib/zxcvbn/adjacency_graphs.rb +4 -4
- data/lib/zxcvbn/feedback.rb +37 -37
- data/lib/zxcvbn/frequency_lists.rb +6 -6
- data/lib/zxcvbn/matching.rb +92 -90
- data/lib/zxcvbn/scoring.rb +91 -90
- data/lib/zxcvbn/time_estimates.rb +7 -7
- data/lib/zxcvbn/version.rb +1 -1
- metadata +2 -2
data/lib/zxcvbn/scoring.rb
CHANGED
@@ -7,9 +7,9 @@ module Zxcvbn
|
|
7
7
|
def self.calc_average_degree(graph)
|
8
8
|
average = 0
|
9
9
|
graph.each do |key, neighbors|
|
10
|
-
average += neighbors.count {|n| n }
|
10
|
+
average += neighbors.count {|n| n }.to_f
|
11
11
|
end
|
12
|
-
average /= graph.keys.size
|
12
|
+
average /= graph.keys.size.to_f
|
13
13
|
return average
|
14
14
|
end
|
15
15
|
|
@@ -24,16 +24,16 @@ module Zxcvbn
|
|
24
24
|
def self.nCk(n, k)
|
25
25
|
# http://blog.plover.com/math/choose.html
|
26
26
|
if k > n
|
27
|
-
return 0
|
27
|
+
return 0.0
|
28
28
|
end
|
29
29
|
if k == 0
|
30
|
-
return 1
|
30
|
+
return 1.0
|
31
31
|
end
|
32
|
-
r = 1
|
32
|
+
r = 1.0
|
33
33
|
(1..k).each do |d|
|
34
34
|
r *= n
|
35
35
|
r /= d
|
36
|
-
n -= 1
|
36
|
+
n -= 1.0
|
37
37
|
end
|
38
38
|
return r
|
39
39
|
end
|
@@ -43,7 +43,7 @@ module Zxcvbn
|
|
43
43
|
if n < 2
|
44
44
|
return 1
|
45
45
|
end
|
46
|
-
return (2..
|
46
|
+
return (2..n).reduce(&:*)
|
47
47
|
end
|
48
48
|
|
49
49
|
# ------------------------------------------------------------------------------
|
@@ -83,12 +83,12 @@ module Zxcvbn
|
|
83
83
|
# partition matches into sublists according to ending index j
|
84
84
|
matches_by_j = (0...n).map { [] }
|
85
85
|
matches.each do |m|
|
86
|
-
matches_by_j[m[
|
86
|
+
matches_by_j[m["j"]] << m
|
87
87
|
end
|
88
88
|
|
89
89
|
# small detail: for deterministic output, sort each sublist by i.
|
90
90
|
matches_by_j.each do |lst|
|
91
|
-
lst.sort_by!{|m| m[
|
91
|
+
lst.sort_by!{|m| m["i"] }
|
92
92
|
end
|
93
93
|
|
94
94
|
optimal = {
|
@@ -96,24 +96,24 @@ module Zxcvbn
|
|
96
96
|
# password prefix up to k, inclusive.
|
97
97
|
# if there is no length-l sequence that scores better (fewer guesses) than
|
98
98
|
# a shorter match sequence spanning the same prefix, optimal.m[k][l] is undefined.
|
99
|
-
m
|
99
|
+
"m" => (0...n).map { {} },
|
100
100
|
# same structure as optimal.m -- holds the product term Prod(m.guesses for m in sequence).
|
101
101
|
# optimal.pi allows for fast (non-looping) updates to the minimization function.
|
102
|
-
pi
|
102
|
+
"pi" => (0...n).map { {} },
|
103
103
|
# same structure as optimal.m -- holds the overall metric.
|
104
|
-
g
|
104
|
+
"g" => (0...n).map { {} },
|
105
105
|
}
|
106
106
|
|
107
107
|
# helper: considers whether a length-l sequence ending at match m is better (fewer guesses)
|
108
108
|
# than previously encountered sequences, updating state if so.
|
109
109
|
update = -> (m, l) do
|
110
|
-
k = m[
|
110
|
+
k = m["j"]
|
111
111
|
pi = estimate_guesses(m, password)
|
112
112
|
if l > 1
|
113
113
|
# we're considering a length-l sequence ending with match m:
|
114
114
|
# obtain the product term in the minimization function by multiplying m's guesses
|
115
115
|
# by the product of the length-(l-1) sequence ending just before m, at m.i - 1.
|
116
|
-
pi *= optimal[
|
116
|
+
pi *= optimal["pi"][m["i"] - 1][l - 1]
|
117
117
|
end
|
118
118
|
# calculate the minimization func
|
119
119
|
g = factorial(l) * pi
|
@@ -123,7 +123,7 @@ module Zxcvbn
|
|
123
123
|
# update state if new best.
|
124
124
|
# first see if any competing sequences covering this prefix, with l or fewer matches,
|
125
125
|
# fare better than this sequence. if so, skip it and return.
|
126
|
-
optimal[
|
126
|
+
optimal["g"][k].each do |competing_l, competing_g|
|
127
127
|
if competing_l > l
|
128
128
|
next
|
129
129
|
end
|
@@ -132,18 +132,18 @@ module Zxcvbn
|
|
132
132
|
end
|
133
133
|
end
|
134
134
|
# this sequence might be part of the final optimal sequence.
|
135
|
-
optimal[
|
136
|
-
optimal[
|
137
|
-
optimal[
|
135
|
+
optimal["g"][k][l] = g
|
136
|
+
optimal["m"][k][l] = m
|
137
|
+
optimal["pi"][k][l] = pi
|
138
138
|
end
|
139
139
|
|
140
140
|
# helper: make bruteforce match objects spanning i to j, inclusive.
|
141
141
|
make_bruteforce_match = -> (i, j) do
|
142
142
|
return {
|
143
|
-
pattern
|
144
|
-
token
|
145
|
-
i
|
146
|
-
j
|
143
|
+
"pattern" => 'bruteforce',
|
144
|
+
"token" => password[i..j],
|
145
|
+
"i" => i,
|
146
|
+
"j" => j
|
147
147
|
}
|
148
148
|
end
|
149
149
|
|
@@ -157,13 +157,13 @@ module Zxcvbn
|
|
157
157
|
# see if adding these new matches to any of the sequences in optimal[i-1]
|
158
158
|
# leads to new bests.
|
159
159
|
m = make_bruteforce_match.call(i, k);
|
160
|
-
optimal[
|
160
|
+
optimal["m"][i-1].each do |l, last_m|
|
161
161
|
l = l.to_i
|
162
162
|
# corner: an optimal sequence will never have two adjacent bruteforce matches.
|
163
163
|
# it is strictly better to have a single bruteforce match spanning the same region:
|
164
164
|
# same contribution to the guess product with a lower length.
|
165
165
|
# --> safe to skip those cases.
|
166
|
-
if last_m[
|
166
|
+
if last_m["pattern"] == 'bruteforce'
|
167
167
|
next
|
168
168
|
end
|
169
169
|
# try adding m to this length-l sequence.
|
@@ -178,11 +178,11 @@ module Zxcvbn
|
|
178
178
|
optimal_match_sequence = []
|
179
179
|
k = n - 1
|
180
180
|
# find the final best sequence length and score
|
181
|
-
l, g = (optimal[
|
181
|
+
l, g = (optimal["g"][k] || []).min_by{|candidate_l, candidate_g| candidate_g || 0 }
|
182
182
|
while k >= 0
|
183
|
-
m = optimal[
|
183
|
+
m = optimal["m"][k][l]
|
184
184
|
optimal_match_sequence.unshift(m)
|
185
|
-
k = m[
|
185
|
+
k = m["i"] - 1
|
186
186
|
l -= 1
|
187
187
|
end
|
188
188
|
return optimal_match_sequence
|
@@ -190,8 +190,8 @@ module Zxcvbn
|
|
190
190
|
|
191
191
|
(0...n).each do |k|
|
192
192
|
matches_by_j[k].each do |m|
|
193
|
-
if m[
|
194
|
-
optimal[
|
193
|
+
if m["i"] > 0
|
194
|
+
optimal["m"][m["i"] - 1].keys.each do |l|
|
195
195
|
update.call(m, l + 1)
|
196
196
|
end
|
197
197
|
else
|
@@ -208,15 +208,15 @@ module Zxcvbn
|
|
208
208
|
if password.length == 0
|
209
209
|
guesses = 1
|
210
210
|
else
|
211
|
-
guesses = optimal[
|
211
|
+
guesses = optimal["g"][n - 1][optimal_l]
|
212
212
|
end
|
213
213
|
|
214
214
|
# final result object
|
215
215
|
return {
|
216
|
-
password
|
217
|
-
guesses
|
218
|
-
guesses_log10
|
219
|
-
sequence
|
216
|
+
"password" => password,
|
217
|
+
"guesses" => guesses,
|
218
|
+
"guesses_log10" => Math.log10(guesses),
|
219
|
+
"sequence" => optimal_match_sequence
|
220
220
|
}
|
221
221
|
end
|
222
222
|
|
@@ -224,36 +224,36 @@ module Zxcvbn
|
|
224
224
|
# guess estimation -- one function per match pattern ---------------------------
|
225
225
|
# ------------------------------------------------------------------------------
|
226
226
|
def self.estimate_guesses(match, password)
|
227
|
-
if match[
|
228
|
-
return match[
|
227
|
+
if match["guesses"]
|
228
|
+
return match["guesses"] # a match's guess estimate doesn't change. cache it.
|
229
229
|
end
|
230
230
|
min_guesses = 1
|
231
|
-
if match[
|
232
|
-
min_guesses = if match[
|
231
|
+
if match["token"].length < password.length
|
232
|
+
min_guesses = if match["token"].length == 1
|
233
233
|
MIN_SUBMATCH_GUESSES_SINGLE_CHAR
|
234
234
|
else
|
235
235
|
MIN_SUBMATCH_GUESSES_MULTI_CHAR
|
236
236
|
end
|
237
237
|
end
|
238
238
|
estimation_functions = {
|
239
|
-
bruteforce
|
240
|
-
dictionary
|
241
|
-
spatial
|
242
|
-
repeat
|
243
|
-
sequence
|
244
|
-
regex
|
245
|
-
date
|
239
|
+
"bruteforce" => method(:bruteforce_guesses),
|
240
|
+
"dictionary" => method(:dictionary_guesses),
|
241
|
+
"spatial" => method(:spatial_guesses),
|
242
|
+
"repeat" => method(:repeat_guesses),
|
243
|
+
"sequence" => method(:sequence_guesses),
|
244
|
+
"regex" => method(:regex_guesses),
|
245
|
+
"date" => method(:date_guesses),
|
246
246
|
}
|
247
|
-
guesses = estimation_functions[match[
|
248
|
-
match[
|
249
|
-
match[
|
250
|
-
return match[
|
247
|
+
guesses = estimation_functions[match["pattern"]].call(match)
|
248
|
+
match["guesses"] = [guesses, min_guesses].max
|
249
|
+
match["guesses_log10"] = Math.log10(match["guesses"])
|
250
|
+
return match["guesses"]
|
251
251
|
end
|
252
252
|
|
253
253
|
MAX_VALUE = 2 ** 1024
|
254
254
|
|
255
255
|
def self.bruteforce_guesses(match)
|
256
|
-
guesses = BRUTEFORCE_CARDINALITY ** match[
|
256
|
+
guesses = BRUTEFORCE_CARDINALITY ** match["token"].length
|
257
257
|
# trying to match JS behaviour here setting a MAX_VALUE to try to acheieve same values as JS library.
|
258
258
|
if guesses > MAX_VALUE
|
259
259
|
guesses = MAX_VALUE
|
@@ -261,21 +261,21 @@ module Zxcvbn
|
|
261
261
|
|
262
262
|
# small detail: make bruteforce matches at minimum one guess bigger than smallest allowed
|
263
263
|
# submatch guesses, such that non-bruteforce submatches over the same [i..j] take precedence.
|
264
|
-
min_guesses = if match[
|
264
|
+
min_guesses = if match["token"].length == 1
|
265
265
|
MIN_SUBMATCH_GUESSES_SINGLE_CHAR + 1
|
266
266
|
else
|
267
267
|
MIN_SUBMATCH_GUESSES_MULTI_CHAR + 1
|
268
268
|
end
|
269
269
|
|
270
|
-
[guesses, min_guesses].max
|
270
|
+
[guesses, min_guesses].max.to_f
|
271
271
|
end
|
272
272
|
|
273
273
|
def self.repeat_guesses(match)
|
274
|
-
return match[
|
274
|
+
return match["base_guesses"] * match["repeat_count"]
|
275
275
|
end
|
276
276
|
|
277
277
|
def self.sequence_guesses(match)
|
278
|
-
first_chr = match[
|
278
|
+
first_chr = match["token"][0]
|
279
279
|
# lower guesses for obvious starting points
|
280
280
|
if ['a', 'A', 'z', 'Z', '0', '1', '9'].include?(first_chr)
|
281
281
|
base_guesses = 4
|
@@ -288,12 +288,12 @@ module Zxcvbn
|
|
288
288
|
base_guesses = 26
|
289
289
|
end
|
290
290
|
end
|
291
|
-
if !match[
|
291
|
+
if !match["ascending"]
|
292
292
|
# need to try a descending sequence in addition to every ascending sequence ->
|
293
293
|
# 2x guesses
|
294
294
|
base_guesses *= 2
|
295
295
|
end
|
296
|
-
return base_guesses * match[
|
296
|
+
return base_guesses * match["token"].length
|
297
297
|
end
|
298
298
|
|
299
299
|
MIN_YEAR_SPACE = 20
|
@@ -301,19 +301,19 @@ module Zxcvbn
|
|
301
301
|
|
302
302
|
def self.regex_guesses(match)
|
303
303
|
char_class_bases = {
|
304
|
-
alpha_lower
|
305
|
-
alpha_upper
|
306
|
-
alpha
|
307
|
-
alphanumeric
|
308
|
-
digits
|
309
|
-
symbols
|
304
|
+
"alpha_lower" => 26,
|
305
|
+
"alpha_upper" => 26,
|
306
|
+
"alpha" => 52,
|
307
|
+
"alphanumeric" => 62,
|
308
|
+
"digits" => 10,
|
309
|
+
"symbols" => 33
|
310
310
|
}
|
311
|
-
if char_class_bases.has_key? match[
|
312
|
-
return char_class_bases[match[
|
313
|
-
elsif match[
|
311
|
+
if char_class_bases.has_key? match["regex_name"]
|
312
|
+
return char_class_bases[match["regex_name"]] ** match["token"].length
|
313
|
+
elsif match["regex_name"] == 'recent_year'
|
314
314
|
# conservative estimate of year space: num years from REFERENCE_YEAR.
|
315
315
|
# if year is close to REFERENCE_YEAR, estimate a year space of MIN_YEAR_SPACE.
|
316
|
-
year_space = (match[
|
316
|
+
year_space = (match["regex_match"][0].to_i - REFERENCE_YEAR).abs
|
317
317
|
year_space = [year_space, MIN_YEAR_SPACE].max
|
318
318
|
return year_space
|
319
319
|
end
|
@@ -321,51 +321,52 @@ module Zxcvbn
|
|
321
321
|
|
322
322
|
def self.date_guesses(match)
|
323
323
|
# base guesses: (year distance from REFERENCE_YEAR) * num_days * num_years
|
324
|
-
year_space = [(match[
|
324
|
+
year_space = [(match["year"] - REFERENCE_YEAR).abs, MIN_YEAR_SPACE].max
|
325
325
|
guesses = year_space * 365
|
326
|
-
|
326
|
+
separator = match["separator"]
|
327
|
+
if !["", nil].include?(separator)
|
327
328
|
# add factor of 4 for separator selection (one of ~4 choices)
|
328
329
|
guesses *= 4
|
329
330
|
end
|
330
331
|
return guesses
|
331
332
|
end
|
332
333
|
|
333
|
-
KEYBOARD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS[
|
334
|
+
KEYBOARD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS["qwerty"])
|
334
335
|
# slightly different for keypad/mac keypad, but close enough
|
335
|
-
KEYPAD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS[
|
336
|
+
KEYPAD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS["keypad"])
|
336
337
|
|
337
|
-
KEYBOARD_STARTING_POSITIONS = ADJACENCY_GRAPHS[
|
338
|
-
KEYPAD_STARTING_POSITIONS = ADJACENCY_GRAPHS[
|
338
|
+
KEYBOARD_STARTING_POSITIONS = ADJACENCY_GRAPHS["qwerty"].keys.size
|
339
|
+
KEYPAD_STARTING_POSITIONS = ADJACENCY_GRAPHS["keypad"].keys.size
|
339
340
|
|
340
341
|
def self.spatial_guesses(match)
|
341
|
-
if ['qwerty', 'dvorak'].include?(match[
|
342
|
+
if ['qwerty', 'dvorak'].include?(match["graph"])
|
342
343
|
s = KEYBOARD_STARTING_POSITIONS;
|
343
344
|
d = KEYBOARD_AVERAGE_DEGREE;
|
344
345
|
else
|
345
346
|
s = KEYPAD_STARTING_POSITIONS;
|
346
347
|
d = KEYPAD_AVERAGE_DEGREE;
|
347
348
|
end
|
348
|
-
guesses = 0
|
349
|
-
ll = match[
|
350
|
-
t = match[
|
349
|
+
guesses = 0.0
|
350
|
+
ll = match["token"].length
|
351
|
+
t = match["turns"]
|
351
352
|
# estimate the number of possible patterns w/ length ll or less with t turns or less.
|
352
353
|
(2..ll).each do |i|
|
353
354
|
possible_turns = [t, i - 1].min
|
354
355
|
(1..possible_turns).each do |j|
|
355
|
-
guesses += nCk(i - 1, j - 1) * s * (d ** j)
|
356
|
+
guesses += nCk((i - 1).to_f, (j - 1).to_f) * s.to_f * (d.to_f ** j.to_f)
|
356
357
|
end
|
357
358
|
end
|
358
359
|
# add extra guesses for shifted keys. (% instead of 5, A instead of a.)
|
359
360
|
# math is similar to extra guesses of l33t substitutions in dictionary matches.
|
360
|
-
if match[
|
361
|
-
ss = match[
|
362
|
-
uu = match[
|
361
|
+
if match["shifted_count"] && match["shifted_count"] != 0
|
362
|
+
ss = match["shifted_count"]
|
363
|
+
uu = match["token"].length - match["shifted_count"] # unshifted count
|
363
364
|
if ss == 0 || uu == 0
|
364
365
|
guesses *= 2
|
365
366
|
else
|
366
367
|
shifted_variations = 0
|
367
368
|
(1..[ss, uu].min).each do |i|
|
368
|
-
shifted_variations += nCk(ss + uu, i)
|
369
|
+
shifted_variations += nCk((ss + uu).to_f, i.to_f)
|
369
370
|
end
|
370
371
|
guesses *= shifted_variations
|
371
372
|
end
|
@@ -374,11 +375,11 @@ module Zxcvbn
|
|
374
375
|
end
|
375
376
|
|
376
377
|
def self.dictionary_guesses(match)
|
377
|
-
match[
|
378
|
-
match[
|
379
|
-
match[
|
380
|
-
reversed_variations = match[
|
381
|
-
return match[
|
378
|
+
match["base_guesses"] = match["rank"] # keep these as properties for display purposes
|
379
|
+
match["uppercase_variations"] = uppercase_variations(match)
|
380
|
+
match["l33t_variations"] = l33t_variations(match)
|
381
|
+
reversed_variations = match["reversed"] && 2 || 1
|
382
|
+
return match["base_guesses"] * match["uppercase_variations"] * match["l33t_variations"] * reversed_variations
|
382
383
|
end
|
383
384
|
|
384
385
|
START_UPPER = /^[A-Z][^A-Z]+$/
|
@@ -387,7 +388,7 @@ module Zxcvbn
|
|
387
388
|
ALL_LOWER = /^[^A-Z]+$/
|
388
389
|
|
389
390
|
def self.uppercase_variations(match)
|
390
|
-
word = match[
|
391
|
+
word = match["token"]
|
391
392
|
if word.match?(ALL_LOWER) || word.downcase === word
|
392
393
|
return 1
|
393
394
|
end
|
@@ -412,13 +413,13 @@ module Zxcvbn
|
|
412
413
|
end
|
413
414
|
|
414
415
|
def self.l33t_variations(match)
|
415
|
-
if !match[
|
416
|
+
if !match["l33t"]
|
416
417
|
return 1
|
417
418
|
end
|
418
419
|
variations = 1
|
419
|
-
match[
|
420
|
+
match["sub"].each do |subbed, unsubbed|
|
420
421
|
# lower-case match.token before calculating: capitalization shouldn't affect l33t calc.
|
421
|
-
chrs = match[
|
422
|
+
chrs = match["token"].downcase.split('')
|
422
423
|
ss = chrs.count{|chr| chr == subbed }
|
423
424
|
uu = chrs.count{|chr| chr == unsubbed }
|
424
425
|
if ss == 0 || uu == 0
|
@@ -4,10 +4,10 @@ module Zxcvbn
|
|
4
4
|
module TimeEstimates
|
5
5
|
def self.estimate_attack_times(guesses)
|
6
6
|
crack_times_seconds = {
|
7
|
-
online_throttling_100_per_hour
|
8
|
-
online_no_throttling_10_per_second
|
9
|
-
offline_slow_hashing_1e4_per_second
|
10
|
-
offline_fast_hashing_1e10_per_second
|
7
|
+
"online_throttling_100_per_hour" => guesses / (100.0 / 3600.0),
|
8
|
+
"online_no_throttling_10_per_second" => guesses / 10.0,
|
9
|
+
"offline_slow_hashing_1e4_per_second" => guesses / 1e4,
|
10
|
+
"offline_fast_hashing_1e10_per_second" => guesses / 1e10
|
11
11
|
}
|
12
12
|
crack_times_display = {};
|
13
13
|
crack_times_seconds.each do |scenario, seconds|
|
@@ -15,9 +15,9 @@ module Zxcvbn
|
|
15
15
|
end
|
16
16
|
|
17
17
|
return {
|
18
|
-
crack_times_seconds
|
19
|
-
crack_times_display
|
20
|
-
score
|
18
|
+
"crack_times_seconds" => crack_times_seconds,
|
19
|
+
"crack_times_display" => crack_times_display,
|
20
|
+
"score" => guesses_to_score(guesses),
|
21
21
|
}
|
22
22
|
end
|
23
23
|
|
data/lib/zxcvbn/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: zxcvbn
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rafael Santos
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-05-
|
11
|
+
date: 2021-05-17 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: Ruby port of Dropbox's zxcvbn.js
|
14
14
|
email:
|