8000 zxcvbn/src/matching.coffee at 3.3.1 · gemini/zxcvbn · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
548 lines (508 loc) · 18.3 KB

matching.coffee

File metadata and controls

548 lines (508 loc) · 18.3 KB
# any 3-tuple that starts or ends with a 2- or 4-digit year,
# with 2 or 0 separator chars (1.1.91 or 1191),
# maybe zero-padded (01-01-91 vs 1-1-91),
# a month between 1 and 12,
# a day between 1 and 31.
#
# note: this isn't true date parsing in that "feb 31st" is allowed,
# this doesn't check for leap years, etc.
#
# recipe:
# start with regex to find maybe-dates, then attempt to map the integers
# onto month-day-year to filter the maybe-dates into dates.
# finally, remove matches that are substrings of other matches to reduce noise.
#
# note: instead of using a lazy or greedy regex to find many dates over the full string,
# this uses a ^...$ regex against every substring of the password -- less performant but leads
# to every possible date match.
matches = []
maybe_date_no_separator = /^\d{4,8}$/
maybe_date_with_separator = ///
^
( \d{1,4} ) # day, month, year
( [\s/\\_.-] ) # separator
( \d{1,2} ) # day, month
\2 # same separator
( \d{1,4} ) # day, month, year
$
///
# dates without separators are between length 4 '1191' and 8 '11111991'
for i in [0..password.length - 4]
for j in [i + 3..i + 7]
break if j >= password.length
token = password[i..j]
continue unless maybe_date_no_separator.exec token
candidates = []
for [k,l] in DATE_SPLITS[token.length]
dmy = @map_ints_to_dmy [
parseInt token[0...k]
parseInt token[k...l]
parseInt token[l...]
]
candidates.push dmy if dmy?
continue unless candidates.length > 0
# at this point: different possible dmy mappings for the same i,j substring.
# match the candidate date that has a year closest to 2000.
# ie, considering '111504', prefer 11-15-04 to 1-1-1504
# (interpreting '04' as 2004)
best_candidate = candidates[0]
min_distance = Math.abs candidates[0].year - 2000
for candidate in candidates[1..]
distance = Math.abs candidate.year - 2000
if distance < min_distance
[best_candidate, min_distance] = [candidate, distance]
matches.push
pattern: 'date'
token: token
i: i
j: j
separator: ''
year: best_candidate.year
month: best_candidate.month
day: best_candidate.day
# dates with separators are between length 6 '1/1/91' and 10 '11/11/1991'
for i in [0..password.length - 6]
for j in [i + 5..i + 9]
break if j >= password.length
token = password[i..j]
rx_match = maybe_date_with_separator.exec token
continue unless rx_match?
dmy = @map_ints_to_dmy [
parseInt rx_match[1]
parseInt rx_match[3]
parseInt rx_match[4]
]
continue unless dmy?
matches.push
pattern: 'date'
token: token
i: i
j: j
separator: rx_match[2]
year: dmy.year
month: dmy.month
day: dmy.day
# matches now contains all valid date strings in a way that is tricky to capture
# with regexes only. while thorough, it will contain some unintuitive noise:
#
# '2015_06_04', in addition to matching 2015_06_04, will also contain
# 5(!) other date matches: 15_06_04, 5_06_04, ..., even 2015 (matched as 5/1/2020)
#
# to reduce noise, remove date matches that are strict substrings of others
filtered_matches = []
for match in matches
is_submatch = false
for other_match in matches
continue if match is other_match
if other_match.i <= match.i and other_match.j >= match.j
is_submatch = true
break
filtered_matches.push match unless is_submatch
@sorted filtered_matches
map_ints_to_dmy: (ints) ->
# given a 3-tuple, discard if:
# middle int is over 31 (for all dmy formats, years are never allowed in the middle)
# middle int is zero
# any int is over the max allowable year
# any int is over two digits but under the min allowable year
# 2 ints are over 31, the max allowable day
# 2 ints are zero
# all ints are over 12, the max allowable month
return if ints[1] > 31 or ints[1] <= 0
over_12 = 0
over_31 = 0
under_1 = 0
for int in ints
return if 99 < int < DATE_MIN_YEAR or int > DATE_MAX_YEAR
over_31 += 1 if int > 31
over_12 += 1 if int > 12
under_1 += 1 if int <= 0
return if over_31 >= 2 or over_12 == 3 or under_1 >= 2
# first look for a four digit year: yyyy + daymonth or daymonth + yyyy
possible_year_splits = [
[ints[2], ints[0..1]] # year last
[ints[0], ints[1..2]] # year first
]
for [y, rest] in possible_year_splits
if DATE_MIN_YEAR <= y <= DATE_MAX_YEAR
dm = @map_ints_to_dm rest
if dm?
return {
year: y
month: dm.month
day: dm.day
}
else
# for a candidate that includes a four-digit year,
# when the remaining ints don't match to a day and month,
# it is not a date.
return
# given no four-digit year, two digit years are the most flexible int to match, so
# try to parse a day-month out of ints[0..1] or ints[1..0]
for [y, rest] in possible_year_splits
dm = @map_ints_to_dm rest
if dm?
y = @two_to_four_digit_year y
return {
year: y
month: dm.month
day: dm.day
}
map_ints_to_dm: (ints) ->
for [d, m] in [ints, ints.slice().reverse()]
if 1 <= d <= 31 and 1 <= m <= 12
return {
day: d
month: m
}
two_to_four_digit_year: (year) ->
if year > 99
year
else if year > 50
year + 1900 # 87 -> 1987
else
year + 2000 # 15 -> 2015
module.exports = matching
0