zxcvbn 0.1.3 → 0.1.4
Sign up to get free protection for your applications and to get access to all the features.
- 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
|