zxcvbn 0.1.3 → 0.1.4
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/.rubocop.yml +33 -2
- data/.travis.yml +1 -1
- data/Gemfile +1 -1
- data/Gemfile.lock +1 -1
- data/bin/console +1 -1
- data/lib/zxcvbn.rb +4 -6
- data/lib/zxcvbn/adjacency_graphs.rb +46 -42
- data/lib/zxcvbn/feedback.rb +51 -53
- data/lib/zxcvbn/frequency_lists.rb +9 -7
- data/lib/zxcvbn/matching.rb +167 -187
- data/lib/zxcvbn/scoring.rb +87 -105
- data/lib/zxcvbn/time_estimates.rb +12 -14
- data/lib/zxcvbn/version.rb +1 -1
- data/zxcvbn.gemspec +1 -1
- metadata +7 -6
data/lib/zxcvbn/scoring.rb
CHANGED
@@ -6,44 +6,40 @@ module Zxcvbn
|
|
6
6
|
# this calculates the average over all keys.
|
7
7
|
def self.calc_average_degree(graph)
|
8
8
|
average = 0
|
9
|
-
graph.each do |
|
10
|
-
average += neighbors.count {|n| n }.to_f
|
9
|
+
graph.each do |_key, neighbors|
|
10
|
+
average += neighbors.count { |n| n }.to_f
|
11
11
|
end
|
12
12
|
average /= graph.keys.size.to_f
|
13
|
-
|
13
|
+
average
|
14
14
|
end
|
15
15
|
|
16
16
|
BRUTEFORCE_CARDINALITY = 10
|
17
17
|
|
18
|
-
MIN_GUESSES_BEFORE_GROWING_SEQUENCE =
|
18
|
+
MIN_GUESSES_BEFORE_GROWING_SEQUENCE = 10_000
|
19
19
|
|
20
20
|
MIN_SUBMATCH_GUESSES_SINGLE_CHAR = 10
|
21
21
|
|
22
22
|
MIN_SUBMATCH_GUESSES_MULTI_CHAR = 50
|
23
23
|
|
24
|
-
def self.
|
24
|
+
def self.nck(n, k) # rubocop:disable Naming/MethodParameterName
|
25
25
|
# http://blog.plover.com/math/choose.html
|
26
|
-
if k > n
|
27
|
-
|
28
|
-
|
29
|
-
if k == 0
|
30
|
-
return 1.0
|
31
|
-
end
|
26
|
+
return 0.0 if k > n
|
27
|
+
return 1.0 if k == 0
|
28
|
+
|
32
29
|
r = 1.0
|
33
30
|
(1..k).each do |d|
|
34
31
|
r *= n
|
35
32
|
r /= d
|
36
33
|
n -= 1.0
|
37
34
|
end
|
38
|
-
|
35
|
+
r
|
39
36
|
end
|
40
37
|
|
41
|
-
def self.factorial(n)
|
38
|
+
def self.factorial(n) # rubocop:disable Naming/MethodParameterName
|
42
39
|
# unoptimized, called only on small n
|
43
|
-
if n < 2
|
44
|
-
|
45
|
-
|
46
|
-
return (2..n).reduce(&:*)
|
40
|
+
return 1 if n < 2
|
41
|
+
|
42
|
+
(2..n).reduce(&:*)
|
47
43
|
end
|
48
44
|
|
49
45
|
# ------------------------------------------------------------------------------
|
@@ -78,7 +74,7 @@ module Zxcvbn
|
|
78
74
|
# D^(l-1) approximates Sum(D^i for i in [1..l-1]
|
79
75
|
|
80
76
|
# ------------------------------------------------------------------------------
|
81
|
-
def self.most_guessable_match_sequence(password, matches, _exclude_additive
|
77
|
+
def self.most_guessable_match_sequence(password, matches, _exclude_additive: false)
|
82
78
|
n = password.length
|
83
79
|
# partition matches into sublists according to ending index j
|
84
80
|
matches_by_j = (0...n).map { [] }
|
@@ -88,7 +84,7 @@ module Zxcvbn
|
|
88
84
|
|
89
85
|
# small detail: for deterministic output, sort each sublist by i.
|
90
86
|
matches_by_j.each do |lst|
|
91
|
-
lst.sort_by!{|m| m["i"] }
|
87
|
+
lst.sort_by! { |m| m["i"] }
|
92
88
|
end
|
93
89
|
|
94
90
|
optimal = {
|
@@ -101,12 +97,12 @@ module Zxcvbn
|
|
101
97
|
# optimal.pi allows for fast (non-looping) updates to the minimization function.
|
102
98
|
"pi" => (0...n).map { {} },
|
103
99
|
# same structure as optimal.m -- holds the overall metric.
|
104
|
-
"g" => (0...n).map { {} }
|
100
|
+
"g" => (0...n).map { {} }
|
105
101
|
}
|
106
102
|
|
107
103
|
# helper: considers whether a length-l sequence ending at match m is better (fewer guesses)
|
108
104
|
# than previously encountered sequences, updating state if so.
|
109
|
-
update =
|
105
|
+
update = lambda do |m, l|
|
110
106
|
k = m["j"]
|
111
107
|
pi = estimate_guesses(m, password)
|
112
108
|
if l > 1
|
@@ -117,19 +113,13 @@ module Zxcvbn
|
|
117
113
|
end
|
118
114
|
# calculate the minimization func
|
119
115
|
g = factorial(l) * pi
|
120
|
-
if !_exclude_additive
|
121
|
-
g += MIN_GUESSES_BEFORE_GROWING_SEQUENCE ** (l - 1)
|
122
|
-
end
|
116
|
+
g += MIN_GUESSES_BEFORE_GROWING_SEQUENCE**(l - 1) if !_exclude_additive
|
123
117
|
# update state if new best.
|
124
118
|
# first see if any competing sequences covering this prefix, with l or fewer matches,
|
125
119
|
# fare better than this sequence. if so, skip it and return.
|
126
|
-
optimal["g"][k].
|
127
|
-
if competing_l > l
|
128
|
-
|
129
|
-
end
|
130
|
-
if competing_g <= g
|
131
|
-
return
|
132
|
-
end
|
120
|
+
optimal["g"][k].find do |competing_l, competing_g|
|
121
|
+
next if competing_l > l
|
122
|
+
return nil if competing_g <= g
|
133
123
|
end
|
134
124
|
# this sequence might be part of the final optimal sequence.
|
135
125
|
optimal["g"][k][l] = g
|
@@ -138,9 +128,9 @@ module Zxcvbn
|
|
138
128
|
end
|
139
129
|
|
140
130
|
# helper: make bruteforce match objects spanning i to j, inclusive.
|
141
|
-
make_bruteforce_match =
|
131
|
+
make_bruteforce_match = lambda do |i, j|
|
142
132
|
return {
|
143
|
-
"pattern" =>
|
133
|
+
"pattern" => "bruteforce",
|
144
134
|
"token" => password[i..j],
|
145
135
|
"i" => i,
|
146
136
|
"j" => j
|
@@ -148,7 +138,7 @@ module Zxcvbn
|
|
148
138
|
end
|
149
139
|
|
150
140
|
# helper: evaluate bruteforce matches ending at k.
|
151
|
-
bruteforce_update =
|
141
|
+
bruteforce_update = lambda do |k|
|
152
142
|
# see if a single bruteforce match spanning the k-prefix is optimal.
|
153
143
|
m = make_bruteforce_match.call(0, k)
|
154
144
|
update.call(m, 1)
|
@@ -156,16 +146,15 @@ module Zxcvbn
|
|
156
146
|
# generate k bruteforce matches, spanning from (i=1, j=k) up to (i=k, j=k).
|
157
147
|
# see if adding these new matches to any of the sequences in optimal[i-1]
|
158
148
|
# leads to new bests.
|
159
|
-
m = make_bruteforce_match.call(i, k)
|
160
|
-
optimal["m"][i-1].each do |l, last_m|
|
149
|
+
m = make_bruteforce_match.call(i, k)
|
150
|
+
optimal["m"][i - 1].each do |l, last_m|
|
161
151
|
l = l.to_i
|
162
152
|
# corner: an optimal sequence will never have two adjacent bruteforce matches.
|
163
153
|
# it is strictly better to have a single bruteforce match spanning the same region:
|
164
154
|
# same contribution to the guess product with a lower length.
|
165
155
|
# --> safe to skip those cases.
|
166
|
-
if last_m["pattern"] ==
|
167
|
-
|
168
|
-
end
|
156
|
+
next if last_m["pattern"] == "bruteforce"
|
157
|
+
|
169
158
|
# try adding m to this length-l sequence.
|
170
159
|
update.call(m, l + 1)
|
171
160
|
end
|
@@ -174,11 +163,11 @@ module Zxcvbn
|
|
174
163
|
|
175
164
|
# helper: step backwards through optimal.m starting at the end,
|
176
165
|
# constructing the final optimal match sequence.
|
177
|
-
unwind =
|
166
|
+
unwind = lambda do |n2|
|
178
167
|
optimal_match_sequence = []
|
179
|
-
k =
|
168
|
+
k = n2 - 1
|
180
169
|
# find the final best sequence length and score
|
181
|
-
l,
|
170
|
+
l, _g = (optimal["g"][k] || []).min_by { |_candidate_l, candidate_g| candidate_g || 0 }
|
182
171
|
while k >= 0
|
183
172
|
m = optimal["m"][k][l]
|
184
173
|
optimal_match_sequence.unshift(m)
|
@@ -191,7 +180,7 @@ module Zxcvbn
|
|
191
180
|
(0...n).each do |k|
|
192
181
|
matches_by_j[k].each do |m|
|
193
182
|
if m["i"] > 0
|
194
|
-
optimal["m"][m["i"] - 1].
|
183
|
+
optimal["m"][m["i"] - 1].each_key do |l|
|
195
184
|
update.call(m, l + 1)
|
196
185
|
end
|
197
186
|
else
|
@@ -205,14 +194,14 @@ module Zxcvbn
|
|
205
194
|
optimal_l = optimal_match_sequence.length
|
206
195
|
|
207
196
|
# corner: empty password
|
208
|
-
if password.
|
209
|
-
|
197
|
+
guesses = if password.empty?
|
198
|
+
1
|
210
199
|
else
|
211
|
-
|
200
|
+
optimal["g"][n - 1][optimal_l]
|
212
201
|
end
|
213
202
|
|
214
203
|
# final result object
|
215
|
-
|
204
|
+
{
|
216
205
|
"password" => password,
|
217
206
|
"guesses" => guesses,
|
218
207
|
"guesses_log10" => Math.log10(guesses),
|
@@ -227,6 +216,7 @@ module Zxcvbn
|
|
227
216
|
if match["guesses"]
|
228
217
|
return match["guesses"] # a match's guess estimate doesn't change. cache it.
|
229
218
|
end
|
219
|
+
|
230
220
|
min_guesses = 1
|
231
221
|
if match["token"].length < password.length
|
232
222
|
min_guesses = if match["token"].length == 1
|
@@ -242,22 +232,20 @@ module Zxcvbn
|
|
242
232
|
"repeat" => method(:repeat_guesses),
|
243
233
|
"sequence" => method(:sequence_guesses),
|
244
234
|
"regex" => method(:regex_guesses),
|
245
|
-
"date" => method(:date_guesses)
|
235
|
+
"date" => method(:date_guesses)
|
246
236
|
}
|
247
237
|
guesses = estimation_functions[match["pattern"]].call(match)
|
248
238
|
match["guesses"] = [guesses, min_guesses].max
|
249
239
|
match["guesses_log10"] = Math.log10(match["guesses"])
|
250
|
-
|
240
|
+
match["guesses"]
|
251
241
|
end
|
252
242
|
|
253
|
-
MAX_VALUE = 2
|
243
|
+
MAX_VALUE = 2**1024
|
254
244
|
|
255
245
|
def self.bruteforce_guesses(match)
|
256
|
-
guesses = BRUTEFORCE_CARDINALITY
|
246
|
+
guesses = BRUTEFORCE_CARDINALITY**match["token"].length
|
257
247
|
# trying to match JS behaviour here setting a MAX_VALUE to try to acheieve same values as JS library.
|
258
|
-
if guesses > MAX_VALUE
|
259
|
-
guesses = MAX_VALUE
|
260
|
-
end
|
248
|
+
guesses = MAX_VALUE if guesses > MAX_VALUE
|
261
249
|
|
262
250
|
# small detail: make bruteforce matches at minimum one guess bigger than smallest allowed
|
263
251
|
# submatch guesses, such that non-bruteforce submatches over the same [i..j] take precedence.
|
@@ -271,29 +259,27 @@ module Zxcvbn
|
|
271
259
|
end
|
272
260
|
|
273
261
|
def self.repeat_guesses(match)
|
274
|
-
|
262
|
+
match["base_guesses"] * match["repeat_count"]
|
275
263
|
end
|
276
264
|
|
277
265
|
def self.sequence_guesses(match)
|
278
266
|
first_chr = match["token"][0]
|
279
267
|
# lower guesses for obvious starting points
|
280
|
-
if [
|
281
|
-
|
268
|
+
base_guesses = if ["a", "A", "z", "Z", "0", "1", "9"].include?(first_chr)
|
269
|
+
4
|
270
|
+
elsif first_chr.match?(/\d/)
|
271
|
+
10
|
282
272
|
else
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
# could give a higher base for uppercase,
|
287
|
-
# assigning 26 to both upper and lower sequences is more conservative.
|
288
|
-
base_guesses = 26
|
289
|
-
end
|
273
|
+
# could give a higher base for uppercase,
|
274
|
+
# assigning 26 to both upper and lower sequences is more conservative.
|
275
|
+
26
|
290
276
|
end
|
291
277
|
if !match["ascending"]
|
292
278
|
# need to try a descending sequence in addition to every ascending sequence ->
|
293
279
|
# 2x guesses
|
294
280
|
base_guesses *= 2
|
295
281
|
end
|
296
|
-
|
282
|
+
base_guesses * match["token"].length
|
297
283
|
end
|
298
284
|
|
299
285
|
MIN_YEAR_SPACE = 20
|
@@ -308,14 +294,14 @@ module Zxcvbn
|
|
308
294
|
"digits" => 10,
|
309
295
|
"symbols" => 33
|
310
296
|
}
|
311
|
-
if char_class_bases.
|
312
|
-
|
313
|
-
elsif match["regex_name"] ==
|
297
|
+
if char_class_bases.key? match["regex_name"]
|
298
|
+
char_class_bases[match["regex_name"]]**match["token"].length
|
299
|
+
elsif match["regex_name"] == "recent_year"
|
314
300
|
# conservative estimate of year space: num years from REFERENCE_YEAR.
|
315
301
|
# if year is close to REFERENCE_YEAR, estimate a year space of MIN_YEAR_SPACE.
|
316
302
|
year_space = (match["regex_match"][0].to_i - REFERENCE_YEAR).abs
|
317
|
-
|
318
|
-
|
303
|
+
[year_space, MIN_YEAR_SPACE].max
|
304
|
+
|
319
305
|
end
|
320
306
|
end
|
321
307
|
|
@@ -328,23 +314,23 @@ module Zxcvbn
|
|
328
314
|
# add factor of 4 for separator selection (one of ~4 choices)
|
329
315
|
guesses *= 4
|
330
316
|
end
|
331
|
-
|
317
|
+
guesses
|
332
318
|
end
|
333
319
|
|
334
|
-
KEYBOARD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS["qwerty"])
|
320
|
+
KEYBOARD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS["qwerty"]).freeze
|
335
321
|
# slightly different for keypad/mac keypad, but close enough
|
336
|
-
KEYPAD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS["keypad"])
|
322
|
+
KEYPAD_AVERAGE_DEGREE = calc_average_degree(ADJACENCY_GRAPHS["keypad"]).freeze
|
337
323
|
|
338
324
|
KEYBOARD_STARTING_POSITIONS = ADJACENCY_GRAPHS["qwerty"].keys.size
|
339
325
|
KEYPAD_STARTING_POSITIONS = ADJACENCY_GRAPHS["keypad"].keys.size
|
340
326
|
|
341
327
|
def self.spatial_guesses(match)
|
342
|
-
if [
|
343
|
-
s = KEYBOARD_STARTING_POSITIONS
|
344
|
-
d = KEYBOARD_AVERAGE_DEGREE
|
328
|
+
if ["qwerty", "dvorak"].include?(match["graph"])
|
329
|
+
s = KEYBOARD_STARTING_POSITIONS
|
330
|
+
d = KEYBOARD_AVERAGE_DEGREE
|
345
331
|
else
|
346
|
-
s = KEYPAD_STARTING_POSITIONS
|
347
|
-
d = KEYPAD_AVERAGE_DEGREE
|
332
|
+
s = KEYPAD_STARTING_POSITIONS
|
333
|
+
d = KEYPAD_AVERAGE_DEGREE
|
348
334
|
end
|
349
335
|
guesses = 0.0
|
350
336
|
ll = match["token"].length
|
@@ -353,7 +339,7 @@ module Zxcvbn
|
|
353
339
|
(2..ll).each do |i|
|
354
340
|
possible_turns = [t, i - 1].min
|
355
341
|
(1..possible_turns).each do |j|
|
356
|
-
guesses +=
|
342
|
+
guesses += nck((i - 1).to_f, (j - 1).to_f) * s.to_f * (d.to_f**j.to_f)
|
357
343
|
end
|
358
344
|
end
|
359
345
|
# add extra guesses for shifted keys. (% instead of 5, A instead of a.)
|
@@ -366,12 +352,12 @@ module Zxcvbn
|
|
366
352
|
else
|
367
353
|
shifted_variations = 0
|
368
354
|
(1..[ss, uu].min).each do |i|
|
369
|
-
shifted_variations +=
|
355
|
+
shifted_variations += nck((ss + uu).to_f, i.to_f)
|
370
356
|
end
|
371
357
|
guesses *= shifted_variations
|
372
358
|
end
|
373
359
|
end
|
374
|
-
|
360
|
+
guesses
|
375
361
|
end
|
376
362
|
|
377
363
|
def self.dictionary_guesses(match)
|
@@ -379,49 +365,45 @@ module Zxcvbn
|
|
379
365
|
match["uppercase_variations"] = uppercase_variations(match)
|
380
366
|
match["l33t_variations"] = l33t_variations(match)
|
381
367
|
reversed_variations = match["reversed"] && 2 || 1
|
382
|
-
|
368
|
+
match["base_guesses"] * match["uppercase_variations"] * match["l33t_variations"] * reversed_variations
|
383
369
|
end
|
384
370
|
|
385
|
-
START_UPPER = /^[A-Z][^A-Z]
|
386
|
-
END_UPPER = /^[^A-Z]+[A-Z]
|
387
|
-
ALL_UPPER = /^[^a-z]
|
388
|
-
ALL_LOWER = /^[^A-Z]
|
371
|
+
START_UPPER = /^[A-Z][^A-Z]+$/.freeze
|
372
|
+
END_UPPER = /^[^A-Z]+[A-Z]$/.freeze
|
373
|
+
ALL_UPPER = /^[^a-z]+$/.freeze
|
374
|
+
ALL_LOWER = /^[^A-Z]+$/.freeze
|
389
375
|
|
390
376
|
def self.uppercase_variations(match)
|
391
377
|
word = match["token"]
|
392
|
-
if word.match?(ALL_LOWER) || word.downcase
|
393
|
-
|
394
|
-
end
|
378
|
+
return 1 if word.match?(ALL_LOWER) || word.downcase == word
|
379
|
+
|
395
380
|
# a capitalized word is the most common capitalization scheme,
|
396
381
|
# so it only doubles the search space (uncapitalized + capitalized).
|
397
382
|
# allcaps and end-capitalized are common enough too, underestimate as 2x factor to be safe.
|
398
383
|
[START_UPPER, END_UPPER, ALL_UPPER].each do |regex|
|
399
|
-
if word.match?(regex)
|
400
|
-
return 2
|
401
|
-
end
|
384
|
+
return 2 if word.match?(regex)
|
402
385
|
end
|
403
386
|
# otherwise calculate the number of ways to capitalize U+L uppercase+lowercase letters
|
404
387
|
# with U uppercase letters or less. or, if there's more uppercase than lower (for eg. PASSwORD),
|
405
388
|
# the number of ways to lowercase U+L letters with L lowercase letters or less.
|
406
|
-
uu = word.
|
407
|
-
ll = word.
|
408
|
-
variations = 0
|
389
|
+
uu = word.chars.count { |chr| chr.match?(/[A-Z]/) }
|
390
|
+
ll = word.chars.count { |chr| chr.match?(/[a-z]/) }
|
391
|
+
variations = 0
|
409
392
|
(1..[uu, ll].min).each do |i|
|
410
|
-
variations +=
|
393
|
+
variations += nck(uu + ll, i)
|
411
394
|
end
|
412
|
-
|
395
|
+
variations
|
413
396
|
end
|
414
397
|
|
415
398
|
def self.l33t_variations(match)
|
416
|
-
if !match["l33t"]
|
417
|
-
|
418
|
-
end
|
399
|
+
return 1 if !match["l33t"]
|
400
|
+
|
419
401
|
variations = 1
|
420
402
|
match["sub"].each do |subbed, unsubbed|
|
421
403
|
# lower-case match.token before calculating: capitalization shouldn't affect l33t calc.
|
422
|
-
chrs = match["token"].downcase.
|
423
|
-
ss = chrs.count{|chr| chr == subbed }
|
424
|
-
uu = chrs.count{|chr| chr == unsubbed }
|
404
|
+
chrs = match["token"].downcase.chars
|
405
|
+
ss = chrs.count { |chr| chr == subbed }
|
406
|
+
uu = chrs.count { |chr| chr == unsubbed }
|
425
407
|
if ss == 0 || uu == 0
|
426
408
|
# for this sub, password is either fully subbed (444) or fully unsubbed (aaa)
|
427
409
|
# treat that as doubling the space (attacker needs to try fully subbed chars in addition to
|
@@ -433,12 +415,12 @@ module Zxcvbn
|
|
433
415
|
p = [uu, ss].min
|
434
416
|
possibilities = 0
|
435
417
|
(1..p).each do |i|
|
436
|
-
possibilities +=
|
418
|
+
possibilities += nck(uu + ss, i)
|
437
419
|
end
|
438
420
|
variations *= possibilities
|
439
421
|
end
|
440
422
|
end
|
441
|
-
|
423
|
+
variations
|
442
424
|
end
|
443
425
|
end
|
444
426
|
end
|
@@ -9,36 +9,36 @@ module Zxcvbn
|
|
9
9
|
"offline_slow_hashing_1e4_per_second" => guesses / 1e4,
|
10
10
|
"offline_fast_hashing_1e10_per_second" => guesses / 1e10
|
11
11
|
}
|
12
|
-
crack_times_display = {}
|
12
|
+
crack_times_display = {}
|
13
13
|
crack_times_seconds.each do |scenario, seconds|
|
14
14
|
crack_times_display[scenario] = display_time(seconds)
|
15
15
|
end
|
16
16
|
|
17
|
-
|
17
|
+
{
|
18
18
|
"crack_times_seconds" => crack_times_seconds,
|
19
19
|
"crack_times_display" => crack_times_display,
|
20
|
-
"score" => guesses_to_score(guesses)
|
20
|
+
"score" => guesses_to_score(guesses)
|
21
21
|
}
|
22
22
|
end
|
23
23
|
|
24
24
|
def self.guesses_to_score(guesses)
|
25
|
-
delta = 5
|
25
|
+
delta = 5
|
26
26
|
if guesses < 1e3 + delta
|
27
27
|
# risky password: "too guessable"
|
28
|
-
|
28
|
+
0
|
29
29
|
elsif guesses < 1e6 + delta
|
30
30
|
# modest protection from throttled online attacks: "very guessable"
|
31
|
-
|
31
|
+
1
|
32
32
|
elsif guesses < 1e8 + delta
|
33
33
|
# modest protection from unthrottled online attacks: "somewhat guessable"
|
34
|
-
|
34
|
+
2
|
35
35
|
elsif guesses < 1e10 + delta
|
36
36
|
# modest protection from offline attacks: "safely unguessable"
|
37
37
|
# assuming a salted, slow hash function like bcrypt, scrypt, PBKDF2, argon, etc
|
38
|
-
|
38
|
+
3
|
39
39
|
else
|
40
40
|
# strong protection from offline attacks under same scenario: "very unguessable"
|
41
|
-
|
41
|
+
4
|
42
42
|
end
|
43
43
|
end
|
44
44
|
|
@@ -50,7 +50,7 @@ module Zxcvbn
|
|
50
50
|
year = month * 12
|
51
51
|
century = year * 100
|
52
52
|
display_num, display_str = if seconds < 1
|
53
|
-
[nil,
|
53
|
+
[nil, "less than a second"]
|
54
54
|
elsif seconds < minute
|
55
55
|
base = seconds.round
|
56
56
|
[base, "#{base} second"]
|
@@ -70,11 +70,9 @@ module Zxcvbn
|
|
70
70
|
base = (seconds / year).round
|
71
71
|
[base, "#{base} year"]
|
72
72
|
else
|
73
|
-
[nil,
|
74
|
-
end
|
75
|
-
if display_num && display_num != 1
|
76
|
-
display_str += 's'
|
73
|
+
[nil, "centuries"]
|
77
74
|
end
|
75
|
+
display_str += "s" if display_num && display_num != 1
|
78
76
|
display_str
|
79
77
|
end
|
80
78
|
end
|