zxcvbn3
Version:
realistic password strength estimation
772 lines (771 loc) • 31.9 kB
JavaScript
let name;
import { frequency_lists } from './frequency_lists';
import { adjacency_graphs } from './adjacency_graphs';
import { build_graph } from './generate_adjacency_graph';
import { Scoring } from './scoring';
const build_ranked_dict = function (ordered_list) {
const result = {};
let i = 1; // rank starts at 1, not 0
for (let word of Array.from(ordered_list)) {
result[word] = i;
i += 1;
}
return result;
};
const RANKED_DICTIONARIES = {};
for (name in frequency_lists) {
const lst = frequency_lists[name];
RANKED_DICTIONARIES[name] = build_ranked_dict(lst);
}
const GRAPHS = {
qwerty: adjacency_graphs.qwerty,
dvorak: adjacency_graphs.dvorak,
keypad: adjacency_graphs.keypad,
mac_keypad: adjacency_graphs.mac_keypad
};
const 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']
};
const REGEXEN = { recent_year: /19\d\d|200\d|201\d/g };
const DATE_MAX_YEAR = 2050;
const DATE_MIN_YEAR = 1000;
const DATE_SPLITS = {
4: [
[1, 2],
[2, 3] // 91 1 1
],
5: [
[1, 3],
[2, 3] // 11 1 91
],
6: [
[1, 2],
[2, 4],
[4, 5] // 1991 1 1
],
7: [
[1, 3],
[2, 3],
[4, 5],
[4, 6] // 1991 11 1
],
8: [
[2, 4],
[4, 6] // 1991 11 11
]
};
export class Matching {
static empty(obj) {
return ((() => {
const result = [];
for (let k in obj) {
result.push(k);
}
return result;
})()).length === 0;
}
;
static extend(lst, lst2) { return lst.push.apply(lst, lst2); }
static translate(string, chr_map) { return (Array.from(string.split('')).map((chr) => chr_map[chr] || chr)).join(''); }
static mod(n, m) { return ((n % m) + m) % m; } // mod impl that works for negative numbers
static sorted(matches) {
// sort on i primary, j secondary
return matches.sort((m1, m2) => (m1.i - m2.i) || (m1.j - m2.j));
}
// ------------------------------------------------------------------------------
// omnimatch -- combine everything ----------------------------------------------
// ------------------------------------------------------------------------------
static omnimatch(password, options) {
const matches = [];
const matchers = [
this.dictionary_match,
this.reverse_dictionary_match,
this.l33t_match,
this.spatial_match,
this.repeat_match,
this.sequence_match,
this.regex_match,
this.date_match
];
for (let matcher of Array.from(matchers)) {
this.extend(matches, matcher.call(this, password, options === null || options === void 0 ? void 0 : options.keyboard_layouts));
}
return this.sorted(matches);
}
//-------------------------------------------------------------------------------
// dictionary match (common passwords, english, last names, etc) ----------------
//-------------------------------------------------------------------------------
static dictionary_match(password, _ranked_dictionaries = RANKED_DICTIONARIES) {
const matches = [];
const len = password.length;
const password_lower = password.toLowerCase();
for (let dictionary_name in _ranked_dictionaries) {
const ranked_dict = _ranked_dictionaries[dictionary_name];
for (let i = 0, end = len, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
for (let j = i, end1 = len, asc1 = i <= end1; asc1 ? j < end1 : j > end1; asc1 ? j++ : j--) {
if (password_lower.slice(i, +j + 1 || undefined) in ranked_dict) {
const word = password_lower.slice(i, +j + 1 || undefined);
const rank = ranked_dict[word];
matches.push({
pattern: 'dictionary',
i,
j,
token: password.slice(i, +j + 1 || undefined),
matched_word: word,
rank,
dictionary_name,
reversed: false,
l33t: false
});
}
}
}
}
return this.sorted(matches);
}
static reverse_dictionary_match(password, _ranked_dictionaries) {
if (_ranked_dictionaries == null) {
_ranked_dictionaries = RANKED_DICTIONARIES;
}
const reversed_password = password.split('').reverse().join('');
const matches = this.dictionary_match(reversed_password, _ranked_dictionaries);
for (let match of Array.from(matches)) {
match.token = match.token.split('').reverse().join(''); // reverse back
match.reversed = true;
// map coordinates back to original string
[match.i, match.j] = Array.from([
password.length - 1 - match.j,
password.length - 1 - match.i
]);
}
return this.sorted(matches);
}
static set_user_input_dictionary(ordered_list) {
return 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
static relevant_l33t_subtable(password, table) {
const password_chars = {};
for (let chr of Array.from(password.split(''))) {
password_chars[chr] = true;
}
const subtable = {};
for (let letter in table) {
const subs = table[letter];
const relevant_subs = (Array.from(subs).filter((sub) => sub in password_chars));
if (relevant_subs.length > 0) {
subtable[letter] = relevant_subs;
}
}
return subtable;
}
// returns the list of possible 1337 replacement dictionaries for a given password
static enumerate_l33t_subs(table) {
let k;
const keys = ((() => {
const result = [];
for (k in table) {
result.push(k);
}
return result;
})());
let subs = [[]];
const dedup = function (subs) {
let v, k;
const deduped = [];
const members = {};
for (var sub of Array.from(subs)) {
var assoc = ((() => {
const result1 = [];
for (v = 0; v < sub.length; v++) {
k = sub[v];
result1.push([k, v]);
}
return result1;
})());
assoc.sort();
const label = ((() => {
const result2 = [];
for (v = 0; v < assoc.length; v++) {
k = assoc[v];
result2.push(k + ',' + v);
}
return result2;
})()).join('-');
if (!(label in members)) {
members[label] = true;
deduped.push(sub);
}
}
return deduped;
};
var helper = function (keys) {
if (!keys.length) {
return;
}
const first_key = keys[0];
const rest_keys = keys.slice(1);
const next_subs = [];
for (let l33t_chr of Array.from(table[first_key])) {
for (let sub of Array.from(subs)) {
let dup_l33t_index = -1;
for (let i = 0, end = sub.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
if (sub[i][0] === l33t_chr) {
dup_l33t_index = i;
break;
}
}
if (dup_l33t_index === -1) {
const sub_extension = sub.concat([[l33t_chr, first_key]]);
next_subs.push(sub_extension);
}
else {
const 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);
return helper(rest_keys);
};
helper(keys);
const sub_dicts = []; // convert from assoc lists to dicts
for (let sub of Array.from(subs)) {
const sub_dict = {};
for (let [l33t_chr, chr] of Array.from(sub)) {
sub_dict[l33t_chr] = chr;
}
sub_dicts.push(sub_dict);
}
return sub_dicts;
}
static l33t_match(password, _ranked_dictionaries = RANKED_DICTIONARIES, _l33t_table = L33T_TABLE) {
let token;
const matches = [];
for (let sub of Array.from(this.enumerate_l33t_subs(this.relevant_l33t_subtable(password, _l33t_table)))) {
if (this.empty(sub)) {
break;
} // corner case: password has no relevant subs.
const subbed_password = this.translate(password, sub);
for (let match of Array.from(this.dictionary_match(subbed_password, _ranked_dictionaries))) {
token = password.slice(match.i, +match.j + 1 || undefined);
if (token.toLowerCase() === match.matched_word) {
continue; // only return the matches that contain an actual substitution
}
var match_sub = {}; // subset of mappings in sub that are in use for this match
for (let subbed_chr in sub) {
const chr = sub[subbed_chr];
if (token.indexOf(subbed_chr) !== -1) {
match_sub[subbed_chr] = chr;
}
}
match.l33t = true;
match.token = token;
match.sub = match_sub;
match.sub_display = ((() => {
const result = [];
for (let k in match_sub) {
const v = match_sub[k];
result.push(`${k} -> ${v}`);
}
return result;
})()).join(', ');
matches.push(match);
}
}
return this.sorted(matches.filter(match => // filter single-character l33t matches to reduce noise.
// otherwise '1' matches 'i', '4' matches 'a', both very common English words
// with low dictionary rank.
match.token.length > 1));
}
// ------------------------------------------------------------------------------
// spatial match (qwerty/dvorak/keypad) -----------------------------------------
// ------------------------------------------------------------------------------
static spatial_match(password, additionalGraphs) {
const _graphs = GRAPHS;
for (const [name, { layout, slanted }] of Object.entries(additionalGraphs || {})) {
if (layout) {
GRAPHS[name] = build_graph(layout, slanted);
}
}
const matches = [];
for (let graph_name in _graphs) {
const graph = _graphs[graph_name];
this.extend(matches, this.spatial_match_helper(password, graph, graph_name));
}
return this.sorted(matches);
}
static spatial_match_helper(password, graph, graph_name) {
const matches = [];
let i = 0;
while (i < (password.length - 1)) {
var shifted_count;
let j = i + 1;
let last_direction = null;
let turns = 0;
if (['qwerty', 'dvorak'].includes(graph_name) && this.SHIFTED_RX.exec(password.charAt(i))) {
// initial character is shifted
shifted_count = 1;
}
else {
shifted_count = 0;
}
while (true) {
const prev_char = password.charAt(j - 1);
let found = false;
let found_direction = -1;
let cur_direction = -1;
const adjacents = graph[prev_char] || [];
// consider growing pattern by one character if j hasn't gone over the edge.
if (j < password.length) {
const cur_char = password.charAt(j);
for (let adj of Array.from(adjacents)) {
cur_direction += 1;
if (adj && (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,
j: j - 1,
token: password.slice(i, j),
graph: graph_name,
turns,
shifted_count
});
}
// ...and then start a new search for the rest of the password.
i = j;
break;
}
}
}
return matches;
}
//-------------------------------------------------------------------------------
// repeats (aaa, abcabcabc) and sequences (abcdef) ------------------------------
//-------------------------------------------------------------------------------
static repeat_match(password, options) {
const matches = [];
const greedy = /(.+)\1+/g;
const lazy = /(.+?)\1+/g;
const lazy_anchored = /^(.+?)\1+$/;
let lastIndex = 0;
while (lastIndex < password.length) {
var base_token, match;
greedy.lastIndex = (lazy.lastIndex = lastIndex);
const greedy_match = greedy.exec(password);
const lazy_match = lazy.exec(password);
if (greedy_match == null) {
break;
}
if (greedy_match[0].length > lazy_match[0].length) {
// greedy beats lazy for 'aabaab'
// greedy: [aabaab, aab]
// lazy: [aa, a]
match = greedy_match;
// greedy's repeated string might itself be repeated, eg.
// aabaab in aabaabaabaab.
// run an anchored lazy match on greedy's repeated string
// to find the shortest repeated string
base_token = lazy_anchored.exec(match[0])[1];
}
else {
// lazy beats greedy for 'aaaaa'
// greedy: [aaaa, aa]
// lazy: [aaaaa, a]
match = lazy_match;
base_token = match[1];
}
const [i, j] = Array.from([match.index, (match.index + match[0].length) - 1]);
// recursively match and score the base string
const base_analysis = Scoring.most_guessable_match_sequence(base_token, this.omnimatch(base_token, options));
const base_matches = base_analysis.sequence;
const base_guesses = base_analysis.guesses;
matches.push({
pattern: 'repeat',
i,
j,
token: match[0],
base_token,
base_guesses,
base_matches,
repeat_count: match[0].length / base_token.length
});
lastIndex = j + 1;
}
return matches;
}
static sequence_match(password) {
// Identifies sequences by looking for repeated differences in unicode codepoint.
// this allows skipping, such as 9753, and also matches some extended unicode sequences
// such as Greek and Cyrillic alphabets.
//
// for example, consider the input 'abcdb975zy'
//
// password: a b c d b 9 7 5 z y
// index: 0 1 2 3 4 5 6 7 8 9
// delta: 1 1 1 -2 -41 -2 -2 69 1
//
// expected result:
// [(i, j, delta), ...] = [(0, 3, 1), (5, 7, -2), (8, 9, 1)]
if (password.length === 1) {
return [];
}
const update = (i, j, delta) => {
if (((j - i) > 1) || (Math.abs(delta) === 1)) {
let middle;
if (0 < (middle = Math.abs(delta)) && middle <= this.MAX_DELTA) {
let sequence_name, sequence_space;
const token = password.slice(i, +j + 1 || undefined);
if (/^[a-z]+$/.test(token)) {
sequence_name = 'lower';
sequence_space = 26;
}
else if (/^[A-Z]+$/.test(token)) {
sequence_name = 'upper';
sequence_space = 26;
}
else if (/^\d+$/.test(token)) {
sequence_name = 'digits';
sequence_space = 10;
}
else {
// conservatively stick with roman alphabet size.
// (this could be improved)
sequence_name = 'unicode';
sequence_space = 26;
}
return result.push({
pattern: 'sequence',
i,
j,
token: password.slice(i, +j + 1 || undefined),
sequence_name,
sequence_space,
ascending: delta > 0
});
}
}
};
var result = [];
let i = 0;
let last_delta = null;
for (let k = 1, end = password.length, asc = 1 <= end; asc ? k < end : k > end; asc ? k++ : k--) {
const delta = password.charCodeAt(k) - password.charCodeAt(k - 1);
if (last_delta == null) {
last_delta = delta;
}
if (delta === last_delta) {
continue;
}
const j = k - 1;
update(i, j, last_delta);
i = j;
last_delta = delta;
}
update(i, password.length - 1, last_delta);
return result;
}
//-------------------------------------------------------------------------------
// regex matching ---------------------------------------------------------------
//-------------------------------------------------------------------------------
static regex_match(password) {
const _regexen = REGEXEN;
const matches = [];
for (name in _regexen) {
var rx_match;
const regex = _regexen[name];
regex.lastIndex = 0; // keeps regex_match stateless
while ((rx_match = regex.exec(password))) {
const token = rx_match[0];
matches.push({
pattern: 'regex',
token,
i: rx_match.index,
j: (rx_match.index + rx_match[0].length) - 1,
regex_name: name,
regex_match: rx_match
});
}
}
return this.sorted(matches);
}
//-------------------------------------------------------------------------------
// date matching ----------------------------------------------------------------
//-------------------------------------------------------------------------------
static date_match(password) {
// a "date" is recognized as:
// 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.
let dmy, i, j, token;
let asc, end;
let asc2, end2;
const matches = [];
const maybe_date_no_separator = /^\d{4,8}$/;
const maybe_date_with_separator = new RegExp(`\
^\
(\\d{1,4})\
([\\s/\\\\_.-])\
(\\d{1,2})\
\\2\
(\\d{1,4})\
$\
`);
// dates without separators are between length 4 '1191' and 8 '11111991'
for (i = 0, end = password.length - 4, asc = 0 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) {
var asc1, end1, start;
for (start = i + 3, j = start, end1 = i + 7, asc1 = start <= end1; asc1 ? j <= end1 : j >= end1; asc1 ? j++ : j--) {
if (j >= password.length) {
break;
}
token = password.slice(i, +j + 1 || undefined);
if (!maybe_date_no_separator.exec(token)) {
continue;
}
const candidates = [];
for (let [k, l] of Array.from(DATE_SPLITS[token.length])) {
dmy = this.map_ints_to_dmy([
parseInt(token.slice(0, k)),
parseInt(token.slice(k, l)),
parseInt(token.slice(l))
]);
if (dmy != null) {
candidates.push(dmy);
}
}
if (!(candidates.length > 0)) {
continue;
}
// at this point: different possible dmy mappings for the same i,j substring.
// match the candidate date that likely takes the fewest guesses: a year closest to 2000.
// (scoring.REFERENCE_YEAR).
//
// ie, considering '111504', prefer 11-15-04 to 1-1-1504
// (interpreting '04' as 2004)
let best_candidate = candidates[0];
const metric = (candidate) => Math.abs(candidate.year - Scoring.REFERENCE_YEAR);
let min_distance = metric(candidates[0]);
for (let candidate of Array.from(candidates.slice(1))) {
const distance = metric(candidate);
if (distance < min_distance) {
[best_candidate, min_distance] = Array.from([candidate, distance]);
}
}
matches.push({
pattern: 'date',
token,
i,
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 = 0, end2 = password.length - 6, asc2 = 0 <= end2; asc2 ? i <= end2 : i >= end2; asc2 ? i++ : i--) {
var asc3, end3, start1;
for (start1 = i + 5, j = start1, end3 = i + 9, asc3 = start1 <= end3; asc3 ? j <= end3 : j >= end3; asc3 ? j++ : j--) {
if (j >= password.length) {
break;
}
token = password.slice(i, +j + 1 || undefined);
const rx_match = maybe_date_with_separator.exec(token);
if (rx_match == null) {
continue;
}
dmy = this.map_ints_to_dmy([
parseInt(rx_match[1]),
parseInt(rx_match[3]),
parseInt(rx_match[4])
]);
if (dmy == null) {
continue;
}
matches.push({
pattern: 'date',
token,
i,
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
return this.sorted(matches.filter(function (match) {
let is_submatch = false;
for (let other_match of Array.from(matches)) {
if (match === other_match) {
continue;
}
if ((other_match.i <= match.i) && (other_match.j >= match.j)) {
is_submatch = true;
break;
}
}
return !is_submatch;
}));
}
static 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
let dm, rest, y;
if ((ints[1] > 31) || (ints[1] <= 0)) {
return;
}
let over_12 = 0;
let over_31 = 0;
let under_1 = 0;
for (let int of Array.from(ints)) {
if ((99 < int && int < DATE_MIN_YEAR) || (int > DATE_MAX_YEAR)) {
return;
}
if (int > 31) {
over_31 += 1;
}
if (int > 12) {
over_12 += 1;
}
if (int <= 0) {
under_1 += 1;
}
}
if ((over_31 >= 2) || (over_12 === 3) || (under_1 >= 2)) {
return;
}
// first look for a four digit year: yyyy + daymonth or daymonth + yyyy
const possible_year_splits = [
[ints[2], ints.slice(0, 2)],
[ints[0], ints.slice(1, 3)] // year first
];
for ([y, rest] of Array.from(possible_year_splits)) {
if (DATE_MIN_YEAR <= y && y <= DATE_MAX_YEAR) {
dm = this.map_ints_to_dm(rest);
if (dm != null) {
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] of Array.from(possible_year_splits)) {
dm = this.map_ints_to_dm(rest);
if (dm != null) {
y = this.two_to_four_digit_year(y);
return {
year: y,
month: dm.month,
day: dm.day
};
}
}
}
static map_ints_to_dm(ints) {
for (let [d, m] of [ints, ints.slice().reverse()]) {
if ((1 <= d && d <= 31) && (1 <= m && m <= 12)) {
return {
day: d,
month: m
};
}
}
}
static two_to_four_digit_year(year) {
if (year > 99) {
return year;
}
else if (year > 50) {
// 87 -> 1987
return year + 1900;
}
else {
// 15 -> 2015
return year + 2000;
}
}
}
Matching.SHIFTED_RX = /[~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?]/;
//# sourceMappingURL=matching.js.map