UNPKG

zxcvbn

Version:

realistic password strength estimation

473 lines (436 loc) 16.2 kB
frequency_lists = require('./frequency_lists') adjacency_graphs = require('./adjacency_graphs') build_ranked_dict = (ordered_list) -> result = {} i = 1 # rank starts at 1, not 0 for word in ordered_list result[word] = i i += 1 result RANKED_DICTIONARIES = passwords: build_ranked_dict frequency_lists.passwords english: build_ranked_dict frequency_lists.english surnames: build_ranked_dict frequency_lists.surnames male_names: build_ranked_dict frequency_lists.male_names female_names: build_ranked_dict frequency_lists.female_names GRAPHS = qwerty: adjacency_graphs.qwerty dvorak: adjacency_graphs.dvorak keypad: adjacency_graphs.keypad mac_keypad: adjacency_graphs.mac_keypad SEQUENCES = lower: 'abcdefghijklmnopqrstuvwxyz' upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' digits: '0123456789' L33T_TABLE = a: ['4', '@'] b: ['8'] c: ['(', '{', '[', '<'] e: ['3'] g: ['6', '9'] i: ['1', '!', '|'] l: ['1', '|', '7'] o: ['0'] s: ['$', '5'] t: ['+', '7'] x: ['%'] z: ['2'] matching = empty: (obj) -> (k for k of obj).length == 0 extend: (lst, lst2) -> lst.push.apply lst, lst2 translate: (string, chr_map) -> (chr_map[chr] or chr for chr in string.split('')).join('') mod: (n, m) -> ((n % m) + m) % m # mod impl that works for negative numbers sorted: (matches) -> matches.sort (m1, m2) -> (m1.i - m2.i) or (m1.j - m2.j) # ------------------------------------------------------------------------------ # omnimatch -- combine everything ---------------------------------------------- # ------------------------------------------------------------------------------ omnimatch: (password) -> matches = [] matchers = [ @dictionary_match @l33t_match @digits_match @year_match @date_match @repeat_match @sequence_match @spatial_match ] for matcher in matchers @extend matches, matcher.call(this, password) @sorted matches #------------------------------------------------------------------------------- # dictionary match (common passwords, english, last names, etc) ---------------- #------------------------------------------------------------------------------- dictionary_match: (password, _ranked_dictionaries = RANKED_DICTIONARIES) -> # _ranked_dictionaries variable is for unit testing purposes matches = [] len = password.length password_lower = password.toLowerCase() for dictionary_name, ranked_dict of _ranked_dictionaries for i in [0...len] for j in [i...len] if password_lower[i..j] of ranked_dict word = password_lower[i..j] rank = ranked_dict[word] matches.push pattern: 'dictionary' i: i j: j token: password[i..j] matched_word: word rank: rank dictionary_name: dictionary_name @sorted matches set_user_input_dictionary: (ordered_list) -> RANKED_DICTIONARIES['user_inputs'] = build_ranked_dict ordered_list.slice() #------------------------------------------------------------------------------- # dictionary match with common l33t substitutions ------------------------------ #------------------------------------------------------------------------------- # makes a pruned copy of l33t_table that only includes password's possible substitutions relevant_l33t_subtable: (password, table) -> password_chars = {} for chr in password.split('') password_chars[chr] = true subtable = {} for letter, subs of table relevant_subs = (sub for sub in subs when sub of password_chars) if relevant_subs.length > 0 subtable[letter] = relevant_subs subtable # returns the list of possible 1337 replacement dictionaries for a given password enumerate_l33t_subs: (table) -> keys = (k for k of table) subs = [[]] dedup = (subs) -> deduped = [] members = {} for sub in subs assoc = ([k,v] for k,v in sub) assoc.sort() label = (k+','+v for k,v in assoc).join('-') unless label of members members[label] = true deduped.push sub deduped helper = (keys) -> return if not keys.length first_key = keys[0] rest_keys = keys[1..] next_subs = [] for l33t_chr in table[first_key] for sub in subs dup_l33t_index = -1 for i in [0...sub.length] if sub[i][0] == l33t_chr dup_l33t_index = i break if dup_l33t_index == -1 sub_extension = sub.concat [[l33t_chr, first_key]] next_subs.push sub_extension else sub_alternative = sub.slice(0) sub_alternative.splice(dup_l33t_index, 1) sub_alternative.push [l33t_chr, first_key] next_subs.push sub next_subs.push sub_alternative subs = dedup next_subs helper(rest_keys) helper(keys) sub_dicts = [] # convert from assoc lists to dicts for sub in subs sub_dict = {} for [l33t_chr, chr] in sub sub_dict[l33t_chr] = chr sub_dicts.push sub_dict sub_dicts l33t_match: (password, _ranked_dictionaries = RANKED_DICTIONARIES, _l33t_table = L33T_TABLE) -> matches = [] for sub in @enumerate_l33t_subs @relevant_l33t_subtable(password, _l33t_table) break if @empty sub # corner case: password has no relevant subs. subbed_password = @translate password, sub for match in @dictionary_match(subbed_password, _ranked_dictionaries) token = password[match.i..match.j] if token.toLowerCase() == match.matched_word continue # only return the matches that contain an actual substitution match_sub = {} # subset of mappings in sub that are in use for this match for subbed_chr, chr of sub when token.indexOf(subbed_chr) != -1 match_sub[subbed_chr] = chr match.l33t = true match.token = token match.sub = match_sub match.sub_display = ("#{k} -> #{v}" for k,v of match_sub).join(', ') matches.push match @sorted matches # ------------------------------------------------------------------------------ # spatial match (qwerty/dvorak/keypad) ----------------------------------------- # ------------------------------------------------------------------------------ spatial_match: (password) -> matches = [] for graph_name, graph of GRAPHS @extend matches, @spatial_match_helper(password, graph, graph_name) @sorted matches spatial_match_helper: (password, graph, graph_name) -> matches = [] i = 0 while i < password.length - 1 j = i + 1 last_direction = null turns = 0 shifted_count = 0 loop prev_char = password.charAt(j-1) found = false found_direction = -1 cur_direction = -1 adjacents = graph[prev_char] or [] # consider growing pattern by one character if j hasn't gone over the edge. if j < password.length cur_char = password.charAt(j) for adj in adjacents cur_direction += 1 if adj and adj.indexOf(cur_char) != -1 found = true found_direction = cur_direction if adj.indexOf(cur_char) == 1 # index 1 in the adjacency means the key is shifted, # 0 means unshifted: A vs a, % vs 5, etc. # for example, 'q' is adjacent to the entry '2@'. # @ is shifted w/ index 1, 2 is unshifted. shifted_count += 1 if last_direction != found_direction # adding a turn is correct even in the initial case when last_direction is null: # every spatial pattern starts with a turn. turns += 1 last_direction = found_direction break # if the current pattern continued, extend j and try to grow again if found j += 1 # otherwise push the pattern discovered so far, if any... else if j - i > 2 # don't consider length 1 or 2 chains. matches.push pattern: 'spatial' i: i j: j-1 token: password[i...j] graph: graph_name turns: turns shifted_count: shifted_count # ...and then start a new search for the rest of the password. i = j break matches #------------------------------------------------------------------------------- # repeats (aaa) and sequences (abcdef) ----------------------------------------- #------------------------------------------------------------------------------- repeat_match: (password) -> min_repeat_length = 3 # TODO allow 2-char repeats? matches = [] i = 0 while i < password.length j = i + 1 loop [prev_char, cur_char] = password[j-1..j] if password.charAt(j-1) == password.charAt(j) j += 1 else j -= 1 if j - i + 1 >= min_repeat_length matches.push pattern: 'repeat' i: i j: j token: password[i..j] repeated_char: password.charAt(i) break i = j + 1 @sorted matches sequence_match: (password) -> min_sequence_length = 3 # TODO allow 2-char sequences? matches = [] for sequence_name, sequence of SEQUENCES for direction in [1, -1] i = 0 while i < password.length unless password[i] in sequence i += 1 continue j = i + 1 sequence_position = sequence.indexOf password[i] while j < password.length # mod by sequence length to allow sequences to wrap around: xyzabc next_sequence_position = @mod sequence_position + direction, sequence.length unless sequence.indexOf(password[j]) == next_sequence_position break j += 1 sequence_position = next_sequence_position j -= 1 if j - i + 1 >= min_sequence_length matches.push pattern: 'sequence' i: i j: j token: password[i..j] sequence_name: sequence_name sequence_space: sequence.length ascending: direction == 1 i = j + 1 @sorted matches #------------------------------------------------------------------------------- # digits, years, dates --------------------------------------------------------- #------------------------------------------------------------------------------- repeat: (chr, n) -> (chr for i in [1..n]).join('') findall: (password, rx) -> matches = [] loop match = password.match rx break if not match match.i = match.index match.j = match.index + match[0].length - 1 matches.push match password = password.replace match[0], @repeat(' ', match[0].length) @sorted matches digits_rx: /\d{3,}/ digits_match: (password) -> for match in @findall password, @digits_rx [i, j] = [match.i, match.j] pattern: 'digits' i: i j: j token: password[i..j] # 4-digit years only. 2-digit years have the same entropy as 2-digit brute force. year_rx: /19\d\d|200\d|201\d/ year_match: (password) -> for match in @findall password, @year_rx [i, j] = [match.i, match.j] pattern: 'year' i: i j: j token: password[i..j] date_match: (password) -> # match dates with separators 1/1/1911 and dates without 111997 @date_without_sep_match(password).concat @date_sep_match(password) date_without_sep_match: (password) -> date_matches = [] for digit_match in @findall password, /\d{4,8}/ # 1197 is length-4, 01011997 is length 8 [i, j] = [digit_match.i, digit_match.j] token = password[i..j] end = token.length candidates_round_1 = [] # parse year alternatives if token.length <= 6 candidates_round_1.push # 2-digit year prefix daymonth: token[2..] year: token[0..1] i: i j: j candidates_round_1.push # 2-digit year suffix daymonth: token[0...end-2] year: token[end-2..] i: i j: j if token.length >= 6 candidates_round_1.push # 4-digit year prefix daymonth: token[4..] year: token[0..3] i: i j: j candidates_round_1.push # 4-digit year suffix daymonth: token[0...end-4] year: token[end-4..] i: i j: j candidates_round_2 = [] # parse day/month alternatives for candidate in candidates_round_1 switch candidate.daymonth.length when 2 # ex. 1 1 97 candidates_round_2.push day: candidate.daymonth[0] month: candidate.daymonth[1] year: candidate.year i: candidate.i j: candidate.j when 3 # ex. 11 1 97 or 1 11 97 candidates_round_2.push day: candidate.daymonth[0..1] month: candidate.daymonth[2] year: candidate.year i: candidate.i j: candidate.j candidates_round_2.push day: candidate.daymonth[0] month: candidate.daymonth[1..2] year: candidate.year i: candidate.i j: candidate.j when 4 # ex. 11 11 97 candidates_round_2.push day: candidate.daymonth[0..1] month: candidate.daymonth[2..3] year: candidate.year i: candidate.i j: candidate.j # final loop: reject invalid dates for candidate in candidates_round_2 day = parseInt(candidate.day) month = parseInt(candidate.month) year = parseInt(candidate.year) [valid, [day, month, year]] = @check_date day, month, year continue unless valid date_matches.push pattern: 'date' i: candidate.i j: candidate.j token: password[i..j] separator: '' day: day month: month year: year date_matches date_rx_year_suffix: /// ( \d{1,2} ) # day or month ( \s | - | / | \\ | _ | \. ) # separator ( \d{1,2} ) # month or day \2 # same separator ( 19\d{2} | 200\d | 201\d | \d{2} ) # year /// date_rx_year_prefix: /// ( 19\d{2} | 200\d | 201\d | \d{2} ) # year ( \s | - | / | \\ | _ | \. ) # separator ( \d{1,2} ) # day or month \2 # same separator ( \d{1,2} ) # month or day /// date_sep_match: (password) -> matches = [] for match in @findall password, @date_rx_year_suffix [match.day, match.month, match.year] = (parseInt(match[k]) for k in [1,3,4]) match.sep = match[2] matches.push match for match in @findall password, @date_rx_year_prefix [match.day, match.month, match.year] = (parseInt(match[k]) for k in [4,3,1]) match.sep = match[2] matches.push match for match in matches [valid, [day, month, year]] = @check_date match.day, match.month, match.year continue unless valid pattern: 'date' i: match.i j: match.j token: password[match.i..match.j] separator: match.sep day: day month: month year: year check_date: (day, month, year) -> if 12 <= month <= 31 and day <= 12 # tolerate both day-month and month-day order [day, month] = [month, day] if day > 31 or month > 12 return [false, []] unless 1900 <= year <= 2019 return [false, []] [true, [day, month, year]] module.exports = matching