UNPKG

zxcvbn3

Version:

realistic password strength estimation

539 lines 22.9 kB
let k, v; import { adjacency_graphs } from './adjacency_graphs'; // on qwerty, 'g' has degree 6, being adjacent to 'ftyhbv'. '\' has degree 1. // this calculates the average over all keys. const calc_average_degree = function (graph) { let average = 0; for (let key in graph) { const neighbors = graph[key]; average += (Array.from(neighbors).filter((n) => n)).length; } average /= ((() => { const result = []; for (k in graph) { v = graph[k]; result.push(k); } return result; })()).length; return average; }; const BRUTEFORCE_CARDINALITY = 10; const MIN_GUESSES_BEFORE_GROWING_SEQUENCE = 10000; const MIN_SUBMATCH_GUESSES_SINGLE_CHAR = 10; const MIN_SUBMATCH_GUESSES_MULTI_CHAR = 50; export class Scoring { static nCk(n, k) { // http://blog.plover.com/math/choose.html if (k > n) { return 0; } if (k === 0) { return 1; } let r = 1; for (let d = 1, end = k, asc = 1 <= end; asc ? d <= end : d >= end; asc ? d++ : d--) { r *= n; r /= d; n -= 1; } return r; } static log10(n) { return Math.log(n) / Math.log(10); } ; // IE doesn't support Math.log10 :( static log2(n) { return Math.log(n) / Math.log(2); } ; static factorial(n) { // unoptimized, called only on small n if (n < 2) { return 1; } let f = 1; for (let i = 2, end = n, asc = 2 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { f *= i; } return f; } // ------------------------------------------------------------------------------ // search --- most guessable match sequence ------------------------------------- // ------------------------------------------------------------------------------ // // takes a sequence of overlapping matches, returns the non-overlapping sequence with // minimum guesses. the following is a O(l_max * (n + m)) dynamic programming algorithm // for a length-n password with m candidate matches. l_max is the maximum optimal // sequence length spanning each prefix of the password. In practice it rarely exceeds 5 and the // search terminates rapidly. // // the optimal "minimum guesses" sequence is here defined to be the sequence that // minimizes the following function: // // g = l! * Product(m.guesses for m in sequence) + D^(l - 1) // // where l is the length of the sequence. // // the factorial term is the number of ways to order l patterns. // // the D^(l-1) term is another length penalty, roughly capturing the idea that an // attacker will try lower-length sequences first before trying length-l sequences. // // for example, consider a sequence that is date-repeat-dictionary. // - an attacker would need to try other date-repeat-dictionary combinations, // hence the product term. // - an attacker would need to try repeat-date-dictionary, dictionary-repeat-date, // ..., hence the factorial term. // - an attacker would also likely try length-1 (dictionary) and length-2 (dictionary-date) // sequences before length-3. assuming at minimum D guesses per pattern type, // D^(l-1) approximates Sum(D^i for i in [1..l-1] // // ------------------------------------------------------------------------------ static most_guessable_match_sequence(password, matches, _exclude_additive) { let guesses, m; let asc4, end4; let _; if (_exclude_additive == null) { _exclude_additive = false; } const n = password.length; // partition matches into sublists according to ending index j const matches_by_j = ((() => { let asc, end; const result = []; for (_ = 0, end = n, asc = 0 <= end; asc ? _ < end : _ > end; asc ? _++ : _--) { result.push([]); } return result; })()); for (m of Array.from(matches)) { matches_by_j[m.j].push(m); } // small detail: for deterministic output, sort each sublist by i. for (let lst of Array.from(matches_by_j)) { lst.sort((m1, m2) => m1.i - m2.i); } const optimal = { // optimal.m[k][l] holds final match in the best length-l match sequence covering the // password prefix up to k, inclusive. // if there is no length-l sequence that scores better (fewer guesses) than // a shorter match sequence spanning the same prefix, optimal.m[k][l] is undefined. m: (((() => { let asc1, end1; const result1 = []; for (_ = 0, end1 = n, asc1 = 0 <= end1; asc1 ? _ < end1 : _ > end1; asc1 ? _++ : _--) { result1.push({}); } return result1; })())), // same structure as optimal.m -- holds the product term Prod(m.guesses for m in sequence). // optimal.pi allows for fast (non-looping) updates to the minimization function. pi: (((() => { let asc2, end2; const result2 = []; for (_ = 0, end2 = n, asc2 = 0 <= end2; asc2 ? _ < end2 : _ > end2; asc2 ? _++ : _--) { result2.push({}); } return result2; })())), // same structure as optimal.m -- holds the overall metric. g: (((() => { let asc3, end3; const result3 = []; for (_ = 0, end3 = n, asc3 = 0 <= end3; asc3 ? _ < end3 : _ > end3; asc3 ? _++ : _--) { result3.push({}); } return result3; })())) }; // helper: considers whether a length-l sequence ending at match m is better (fewer guesses) // than previously encountered sequences, updating state if so. const update = (m, l) => { k = m.j; let pi = this.estimate_guesses(m, password); if (l > 1) { // we're considering a length-l sequence ending with match m: // obtain the product term in the minimization function by multiplying m's guesses // by the product of the length-(l-1) sequence ending just before m, at m.i - 1. pi *= optimal.pi[m.i - 1][l - 1]; } // calculate the minimization func let g = this.factorial(l) * pi; if (!_exclude_additive) { g += Math.pow(MIN_GUESSES_BEFORE_GROWING_SEQUENCE, l - 1); } // update state if new best. // first see if any competing sequences covering this prefix, with l or fewer matches, // fare better than this sequence. if so, skip it and return. for (let competing_l in optimal.g[k]) { const competing_g = optimal.g[k][competing_l]; if (competing_l > l) { continue; } if (competing_g <= g) { return; } } // this sequence might be part of the final optimal sequence. optimal.g[k][l] = g; optimal.m[k][l] = m; return optimal.pi[k][l] = pi; }; // helper: evaluate bruteforce matches ending at k. const bruteforce_update = (k) => { // see if a single bruteforce match spanning the k-prefix is optimal. m = make_bruteforce_match(0, k); update(m, 1); return (() => { const result4 = []; for (var i = 1, end4 = k, asc4 = 1 <= end4; asc4 ? i <= end4 : i >= end4; asc4 ? i++ : i--) { // generate k bruteforce matches, spanning from (i=1, j=k) up to (i=k, j=k). // see if adding these new matches to any of the sequences in optimal[i-1] // leads to new bests. m = make_bruteforce_match(i, k); result4.push((() => { const result5 = []; const object = optimal.m[i - 1]; for (let l in object) { const last_m = object[l]; l = parseInt(l); // corner: an optimal sequence will never have two adjacent bruteforce matches. // it is strictly better to have a single bruteforce match spanning the same region: // same contribution to the guess product with a lower length. // --> safe to skip those cases. if (last_m.pattern === 'bruteforce') { continue; } // try adding m to this length-l sequence. result5.push(update(m, l + 1)); } return result5; })()); } return result4; })(); }; // helper: make bruteforce match objects spanning i to j, inclusive. var make_bruteforce_match = (i, j) => { return { pattern: 'bruteforce', token: password.slice(i, +j + 1 || undefined), i, j }; }; // helper: step backwards through optimal.m starting at the end, // constructing the final optimal match sequence. const unwind = (n) => { const optimal_match_sequence = []; k = n - 1; // find the final best sequence length and score let l = undefined; let g = Infinity; for (let candidate_l in optimal.g[k]) { const candidate_g = optimal.g[k][candidate_l]; if (candidate_g < g) { l = candidate_l; g = candidate_g; } } while (k >= 0) { m = optimal.m[k][l]; optimal_match_sequence.unshift(m); k = m.i - 1; l--; } return optimal_match_sequence; }; for (k = 0, end4 = n, asc4 = 0 <= end4; asc4 ? k < end4 : k > end4; asc4 ? k++ : k--) { for (m of Array.from(matches_by_j[k])) { if (m.i > 0) { for (let l in optimal.m[m.i - 1]) { l = parseInt(l); update(m, l + 1); } } else { update(m, 1); } } bruteforce_update(k); } const optimal_match_sequence = unwind(n); const optimal_l = optimal_match_sequence.length; // corner: empty password if (password.length === 0) { guesses = 1; } else { guesses = optimal.g[n - 1][optimal_l]; } // final result object return { password, guesses, guesses_log10: this.log10(guesses), sequence: optimal_match_sequence }; } // ------------------------------------------------------------------------------ // guess estimation -- one function per match pattern --------------------------- // ------------------------------------------------------------------------------ static estimate_guesses(match, password) { if (match.guesses != null) { return match.guesses; } // a match's guess estimate doesn't change. cache it. let min_guesses = 1; if (match.token.length < password.length) { min_guesses = match.token.length === 1 ? MIN_SUBMATCH_GUESSES_SINGLE_CHAR : MIN_SUBMATCH_GUESSES_MULTI_CHAR; } const estimation_functions = { bruteforce: this.bruteforce_guesses, dictionary: this.dictionary_guesses, spatial: this.spatial_guesses, repeat: this.repeat_guesses, sequence: this.sequence_guesses, regex: this.regex_guesses, date: this.date_guesses }; const guesses = estimation_functions[match.pattern].call(this, match); match.guesses = Math.max(guesses, min_guesses); match.guesses_log10 = this.log10(match.guesses); return match.guesses; } static bruteforce_guesses(match) { let guesses = Math.pow(BRUTEFORCE_CARDINALITY, match.token.length); if (guesses === Number.POSITIVE_INFINITY) { guesses = Number.MAX_VALUE; } // small detail: make bruteforce matches at minimum one guess bigger than smallest allowed // submatch guesses, such that non-bruteforce submatches over the same [i..j] take precedence. const min_guesses = match.token.length === 1 ? MIN_SUBMATCH_GUESSES_SINGLE_CHAR + 1 : MIN_SUBMATCH_GUESSES_MULTI_CHAR + 1; return Math.max(guesses, min_guesses); } static repeat_guesses(match) { return match.base_guesses * match.repeat_count; } static sequence_guesses(match) { let base_guesses; const first_chr = match.token.charAt(0); // lower guesses for obvious starting points if (['a', 'A', 'z', 'Z', '0', '1', '9'].includes(first_chr)) { base_guesses = 4; } else { if (first_chr.match(/\d/)) { base_guesses = 10; // digits } else { // could give a higher base for uppercase, // assigning 26 to both upper and lower sequences is more conservative. base_guesses = 26; } } if (!match.ascending) { // need to try a descending sequence in addition to every ascending sequence -> // 2x guesses base_guesses *= 2; } return base_guesses * match.token.length; } static regex_guesses(match) { const char_class_bases = { alpha_lower: 26, alpha_upper: 26, alpha: 52, alphanumeric: 62, digits: 10, symbols: 33 }; if (match.regex_name in char_class_bases) { return Math.pow(char_class_bases[match.regex_name], match.token.length); } else { switch (match.regex_name) { case 'recent_year': // conservative estimate of year space: num years from REFERENCE_YEAR. // if year is close to REFERENCE_YEAR, estimate a year space of MIN_YEAR_SPACE. var year_space = Math.abs(parseInt(match.regex_match[0]) - this.REFERENCE_YEAR); year_space = Math.max(year_space, this.MIN_YEAR_SPACE); return year_space; } } } static date_guesses(match) { // base guesses: (year distance from REFERENCE_YEAR) * num_days * num_years const year_space = Math.max(Math.abs(match.year - this.REFERENCE_YEAR), this.MIN_YEAR_SPACE); let guesses = year_space * 365; // add factor of 4 for separator selection (one of ~4 choices) if (match.separator) { guesses *= 4; } return guesses; } static spatial_guesses(match) { let d, i, s; let asc, end; if (['qwerty', 'dvorak'].includes(match.graph)) { s = this.KEYBOARD_STARTING_POSITIONS; d = this.KEYBOARD_AVERAGE_DEGREE; } else { s = this.KEYPAD_STARTING_POSITIONS; d = this.KEYPAD_AVERAGE_DEGREE; } let guesses = 0; const L = match.token.length; const t = match.turns; // estimate the number of possible patterns w/ length L or less with t turns or less. for (i = 2, end = L, asc = 2 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { const possible_turns = Math.min(t, i - 1); for (let j = 1, end1 = possible_turns, asc1 = 1 <= end1; asc1 ? j <= end1 : j >= end1; asc1 ? j++ : j--) { guesses += this.nCk(i - 1, j - 1) * s * Math.pow(d, j); } } // add extra guesses for shifted keys. (% instead of 5, A instead of a.) // math is similar to extra guesses of l33t substitutions in dictionary matches. if (match.shifted_count) { const S = match.shifted_count; const U = match.token.length - match.shifted_count; // unshifted count if ((S === 0) || (U === 0)) { guesses *= 2; } else { let asc2, end2; let shifted_variations = 0; for (i = 1, end2 = Math.min(S, U), asc2 = 1 <= end2; asc2 ? i <= end2 : i >= end2; asc2 ? i++ : i--) { shifted_variations += this.nCk(S + U, i); } guesses *= shifted_variations; } } return guesses; } static dictionary_guesses(match) { match.base_guesses = match.rank; // keep these as properties for display purposes match.uppercase_variations = this.uppercase_variations(match); match.l33t_variations = this.l33t_variations(match); const reversed_variations = (match.reversed && 2) || 1; return match.base_guesses * match.uppercase_variations * match.l33t_variations * reversed_variations; } static uppercase_variations(match) { let chr; const word = match.token; if (word.match(this.ALL_LOWER) || (word.toLowerCase() === word)) { return 1; } // a capitalized word is the most common capitalization scheme, // so it only doubles the search space (uncapitalized + capitalized). // allcaps and end-capitalized are common enough too, underestimate as 2x factor to be safe. for (let regex of [this.START_UPPER, this.END_UPPER, this.ALL_UPPER]) { if (word.match(regex)) { return 2; } } // otherwise calculate the number of ways to capitalize U+L uppercase+lowercase letters // with U uppercase letters or less. or, if there's more uppercase than lower (for eg. PASSwORD), // the number of ways to lowercase U+L letters with L lowercase letters or less. const U = ((() => { const result2 = []; for (chr of Array.from(word.split(''))) { if (chr.match(/[A-Z]/)) { result2.push(chr); } } return result2; })()).length; const L = ((() => { const result3 = []; for (chr of Array.from(word.split(''))) { if (chr.match(/[a-z]/)) { result3.push(chr); } } return result3; })()).length; let variations = 0; for (let i = 1, end = Math.min(U, L), asc = 1 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { variations += this.nCk(U + L, i); } return variations; } static l33t_variations(match) { let chr; if (!match.l33t) { return 1; } let variations = 1; for (var subbed in match.sub) { // lower-case match.token before calculating: capitalization shouldn't affect l33t calc. var unsubbed = match.sub[subbed]; var chrs = match.token.toLowerCase().split(''); const S = ((() => { const result2 = []; for (chr of Array.from(chrs)) { if (chr === subbed) { result2.push(chr); } } return result2; })()).length; // num of subbed chars const U = ((() => { const result3 = []; for (chr of Array.from(chrs)) { if (chr === unsubbed) { result3.push(chr); } } return result3; })()).length; // num of unsubbed chars if ((S === 0) || (U === 0)) { // for this sub, password is either fully subbed (444) or fully unsubbed (aaa) // treat that as doubling the space (attacker needs to try fully subbed chars in addition to // unsubbed.) variations *= 2; } else { // this case is similar to capitalization: // with aa44a, U = 3, S = 2, attacker needs to try unsubbed + one sub + two subs const p = Math.min(U, S); let possibilities = 0; for (let i = 1, end = p, asc = 1 <= end; asc ? i <= end : i >= end; asc ? i++ : i--) { possibilities += this.nCk(U + S, i); } variations *= possibilities; } } return variations; } } Scoring.MIN_YEAR_SPACE = 20; Scoring.REFERENCE_YEAR = new Date().getFullYear(); Scoring.KEYBOARD_AVERAGE_DEGREE = calc_average_degree(adjacency_graphs.qwerty); // slightly different for keypad/mac keypad, but close enough Scoring.KEYPAD_AVERAGE_DEGREE = calc_average_degree(adjacency_graphs.keypad); Scoring.KEYBOARD_STARTING_POSITIONS = ((() => { const result = []; for (k in adjacency_graphs.qwerty) { v = adjacency_graphs.qwerty[k]; result.push(k); } return result; })()).length; Scoring.KEYPAD_STARTING_POSITIONS = ((() => { const result1 = []; for (k in adjacency_graphs.keypad) { v = adjacency_graphs.keypad[k]; result1.push(k); } return result1; })()).length; Scoring.START_UPPER = /^[A-Z][^A-Z]+$/; Scoring.END_UPPER = /^[^A-Z]+[A-Z]$/; Scoring.ALL_UPPER = /^[^a-z]+$/; Scoring.ALL_LOWER = /^[^A-Z]+$/; ; //# sourceMappingURL=scoring.js.map