2502 lines
75 KiB
JavaScript
2502 lines
75 KiB
JavaScript
this.zxcvbnts = this.zxcvbnts || {};
|
|
this.zxcvbnts.core = (function (exports) {
|
|
'use strict';
|
|
|
|
const empty = obj => Object.keys(obj).length === 0;
|
|
const extend = (listToExtend, list) => // eslint-disable-next-line prefer-spread
|
|
listToExtend.push.apply(listToExtend, list);
|
|
const translate = (string, chrMap) => {
|
|
const tempArray = string.split('');
|
|
return tempArray.map(char => chrMap[char] || char).join('');
|
|
}; // mod implementation that works for negative numbers
|
|
|
|
const sorted = matches => matches.sort((m1, m2) => m1.i - m2.i || m1.j - m2.j);
|
|
const buildRankedDictionary = orderedList => {
|
|
const result = {};
|
|
let counter = 1; // rank starts at 1, not 0
|
|
|
|
orderedList.forEach(word => {
|
|
result[word] = counter;
|
|
counter += 1;
|
|
});
|
|
return result;
|
|
};
|
|
|
|
var dateSplits = {
|
|
4: [// for length-4 strings, eg 1191 or 9111, two ways to split:
|
|
[1, 2], [2, 3] // 91 1 1
|
|
],
|
|
5: [[1, 3], [2, 3], // [2, 3], // 91 1 11 <- duplicate previous one
|
|
[2, 4] // 91 11 1 <- New and must be added as bug fix
|
|
],
|
|
6: [[1, 2], [2, 4], [4, 5] // 1991 1 1
|
|
],
|
|
// 1111991
|
|
7: [[1, 3], [2, 3], [4, 5], [4, 6] // 1991 11 1
|
|
],
|
|
8: [[2, 4], [4, 6] // 1991 11 11
|
|
]
|
|
};
|
|
|
|
const DATE_MAX_YEAR = 2050;
|
|
const DATE_MIN_YEAR = 1000;
|
|
const DATE_SPLITS = dateSplits;
|
|
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;
|
|
const MIN_YEAR_SPACE = 20; // \xbf-\xdf is a range for almost all special uppercase letter like Ä and so on
|
|
|
|
const START_UPPER = /^[A-Z\xbf-\xdf][^A-Z\xbf-\xdf]+$/;
|
|
const END_UPPER = /^[^A-Z\xbf-\xdf]+[A-Z\xbf-\xdf]$/; // \xdf-\xff is a range for almost all special lowercase letter like ä and so on
|
|
|
|
const ALL_UPPER = /^[A-Z\xbf-\xdf]+$/;
|
|
const ALL_UPPER_INVERTED = /^[^a-z\xdf-\xff]+$/;
|
|
const ALL_LOWER = /^[a-z\xdf-\xff]+$/;
|
|
const ALL_LOWER_INVERTED = /^[^A-Z\xbf-\xdf]+$/;
|
|
const ONE_LOWER = /[a-z\xdf-\xff]/;
|
|
const ONE_UPPER = /[A-Z\xbf-\xdf]/;
|
|
const ALPHA_INVERTED = /[^A-Za-z\xbf-\xdf]/gi;
|
|
const ALL_DIGIT = /^\d+$/;
|
|
const REFERENCE_YEAR = new Date().getFullYear();
|
|
const REGEXEN = {
|
|
recentYear: /19\d\d|200\d|201\d|202\d/g
|
|
};
|
|
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* date matching ----------------------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class MatchDate {
|
|
/*
|
|
* 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.
|
|
*/
|
|
match({
|
|
password
|
|
}) {
|
|
const matches = [...this.getMatchesWithoutSeparator(password), ...this.getMatchesWithSeparator(password)];
|
|
const filteredMatches = this.filterNoise(matches);
|
|
return sorted(filteredMatches);
|
|
}
|
|
|
|
getMatchesWithSeparator(password) {
|
|
const matches = [];
|
|
const maybeDateWithSeparator = /^(\d{1,4})([\s/\\_.-])(\d{1,2})\2(\d{1,4})$/; // # dates with separators are between length 6 '1/1/91' and 10 '11/11/1991'
|
|
|
|
for (let i = 0; i <= Math.abs(password.length - 6); i += 1) {
|
|
for (let j = i + 5; j <= i + 9; j += 1) {
|
|
if (j >= password.length) {
|
|
break;
|
|
}
|
|
|
|
const token = password.slice(i, +j + 1 || 9e9);
|
|
const regexMatch = maybeDateWithSeparator.exec(token);
|
|
|
|
if (regexMatch != null) {
|
|
const dmy = this.mapIntegersToDayMonthYear([parseInt(regexMatch[1], 10), parseInt(regexMatch[3], 10), parseInt(regexMatch[4], 10)]);
|
|
|
|
if (dmy != null) {
|
|
matches.push({
|
|
pattern: 'date',
|
|
token,
|
|
i,
|
|
j,
|
|
separator: regexMatch[2],
|
|
year: dmy.year,
|
|
month: dmy.month,
|
|
day: dmy.day
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
} // eslint-disable-next-line max-statements
|
|
|
|
|
|
getMatchesWithoutSeparator(password) {
|
|
const matches = [];
|
|
const maybeDateNoSeparator = /^\d{4,8}$/;
|
|
|
|
const metric = candidate => Math.abs(candidate.year - REFERENCE_YEAR); // # dates without separators are between length 4 '1191' and 8 '11111991'
|
|
|
|
|
|
for (let i = 0; i <= Math.abs(password.length - 4); i += 1) {
|
|
for (let j = i + 3; j <= i + 7; j += 1) {
|
|
if (j >= password.length) {
|
|
break;
|
|
}
|
|
|
|
const token = password.slice(i, +j + 1 || 9e9);
|
|
|
|
if (maybeDateNoSeparator.exec(token)) {
|
|
const candidates = [];
|
|
const index = token.length;
|
|
const splittedDates = DATE_SPLITS[index];
|
|
splittedDates.forEach(([k, l]) => {
|
|
const dmy = this.mapIntegersToDayMonthYear([parseInt(token.slice(0, k), 10), parseInt(token.slice(k, l), 10), parseInt(token.slice(l), 10)]);
|
|
|
|
if (dmy != null) {
|
|
candidates.push(dmy);
|
|
}
|
|
});
|
|
|
|
if (candidates.length > 0) {
|
|
/*
|
|
* 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 bestCandidate = candidates[0];
|
|
let minDistance = metric(candidates[0]);
|
|
candidates.slice(1).forEach(candidate => {
|
|
const distance = metric(candidate);
|
|
|
|
if (distance < minDistance) {
|
|
bestCandidate = candidate;
|
|
minDistance = distance;
|
|
}
|
|
});
|
|
matches.push({
|
|
pattern: 'date',
|
|
token,
|
|
i,
|
|
j,
|
|
separator: '',
|
|
year: bestCandidate.year,
|
|
month: bestCandidate.month,
|
|
day: bestCandidate.day
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
/*
|
|
* 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
|
|
*/
|
|
|
|
|
|
filterNoise(matches) {
|
|
return matches.filter(match => {
|
|
let isSubmatch = false;
|
|
const matchesLength = matches.length;
|
|
|
|
for (let o = 0; o < matchesLength; o += 1) {
|
|
const otherMatch = matches[o];
|
|
|
|
if (match !== otherMatch) {
|
|
if (otherMatch.i <= match.i && otherMatch.j >= match.j) {
|
|
isSubmatch = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return !isSubmatch;
|
|
});
|
|
}
|
|
/*
|
|
* 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 integers are over 31, the max allowable day
|
|
* 2 integers are zero
|
|
* all integers are over 12, the max allowable month
|
|
*/
|
|
// eslint-disable-next-line complexity, max-statements
|
|
|
|
|
|
mapIntegersToDayMonthYear(integers) {
|
|
if (integers[1] > 31 || integers[1] <= 0) {
|
|
return null;
|
|
}
|
|
|
|
let over12 = 0;
|
|
let over31 = 0;
|
|
let under1 = 0;
|
|
|
|
for (let o = 0, len1 = integers.length; o < len1; o += 1) {
|
|
const int = integers[o];
|
|
|
|
if (int > 99 && int < DATE_MIN_YEAR || int > DATE_MAX_YEAR) {
|
|
return null;
|
|
}
|
|
|
|
if (int > 31) {
|
|
over31 += 1;
|
|
}
|
|
|
|
if (int > 12) {
|
|
over12 += 1;
|
|
}
|
|
|
|
if (int <= 0) {
|
|
under1 += 1;
|
|
}
|
|
}
|
|
|
|
if (over31 >= 2 || over12 === 3 || under1 >= 2) {
|
|
return null;
|
|
}
|
|
|
|
return this.getDayMonth(integers);
|
|
} // eslint-disable-next-line max-statements
|
|
|
|
|
|
getDayMonth(integers) {
|
|
// first look for a four digit year: yyyy + daymonth or daymonth + yyyy
|
|
const possibleYearSplits = [[integers[2], integers.slice(0, 2)], [integers[0], integers.slice(1, 3)] // year first
|
|
];
|
|
const possibleYearSplitsLength = possibleYearSplits.length;
|
|
|
|
for (let j = 0; j < possibleYearSplitsLength; j += 1) {
|
|
const [y, rest] = possibleYearSplits[j];
|
|
|
|
if (DATE_MIN_YEAR <= y && y <= DATE_MAX_YEAR) {
|
|
const dm = this.mapIntegersToDayMonth(rest);
|
|
|
|
if (dm != null) {
|
|
return {
|
|
year: y,
|
|
month: dm.month,
|
|
day: dm.day
|
|
};
|
|
}
|
|
/*
|
|
* for a candidate that includes a four-digit year,
|
|
* when the remaining integers don't match to a day and month,
|
|
* it is not a date.
|
|
*/
|
|
|
|
|
|
return null;
|
|
}
|
|
} // given no four-digit year, two digit years are the most flexible int to match, so
|
|
// try to parse a day-month out of integers[0..1] or integers[1..0]
|
|
|
|
|
|
for (let k = 0; k < possibleYearSplitsLength; k += 1) {
|
|
const [y, rest] = possibleYearSplits[k];
|
|
const dm = this.mapIntegersToDayMonth(rest);
|
|
|
|
if (dm != null) {
|
|
return {
|
|
year: this.twoToFourDigitYear(y),
|
|
month: dm.month,
|
|
day: dm.day
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
mapIntegersToDayMonth(integers) {
|
|
const temp = [integers, integers.slice().reverse()];
|
|
|
|
for (let i = 0; i < temp.length; i += 1) {
|
|
const data = temp[i];
|
|
const day = data[0];
|
|
const month = data[1];
|
|
|
|
if (day >= 1 && day <= 31 && month >= 1 && month <= 12) {
|
|
return {
|
|
day,
|
|
month
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
twoToFourDigitYear(year) {
|
|
if (year > 99) {
|
|
return year;
|
|
}
|
|
|
|
if (year > 50) {
|
|
// 87 -> 1987
|
|
return year + 1900;
|
|
} // 15 -> 2015
|
|
|
|
|
|
return year + 2000;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* This code is from https://github.com/ka-weihe/fastest-levenshtein
|
|
* It was copied into this repo because it doesn't have an esm build which results in error for esm only project
|
|
* TODO if sometimes in the future it will get a esm build we can remove this file and use the original again
|
|
* https://github.com/ka-weihe/fastest-levenshtein/pull/18
|
|
*/
|
|
const peq = new Uint32Array(0x10000);
|
|
|
|
const myers_32 = (a, b) => {
|
|
const n = a.length;
|
|
const m = b.length;
|
|
const lst = 1 << n - 1;
|
|
let pv = -1;
|
|
let mv = 0;
|
|
let sc = n;
|
|
let i = n;
|
|
|
|
while (i--) {
|
|
peq[a.charCodeAt(i)] |= 1 << i;
|
|
}
|
|
|
|
for (i = 0; i < m; i++) {
|
|
let eq = peq[b.charCodeAt(i)];
|
|
const xv = eq | mv;
|
|
eq |= (eq & pv) + pv ^ pv;
|
|
mv |= ~(eq | pv);
|
|
pv &= eq;
|
|
|
|
if (mv & lst) {
|
|
sc++;
|
|
}
|
|
|
|
if (pv & lst) {
|
|
sc--;
|
|
}
|
|
|
|
mv = mv << 1 | 1;
|
|
pv = pv << 1 | ~(xv | mv);
|
|
mv &= xv;
|
|
}
|
|
|
|
i = n;
|
|
|
|
while (i--) {
|
|
peq[a.charCodeAt(i)] = 0;
|
|
}
|
|
|
|
return sc;
|
|
};
|
|
|
|
const myers_x = (b, a) => {
|
|
const n = a.length;
|
|
const m = b.length;
|
|
const mhc = [];
|
|
const phc = [];
|
|
const hsize = Math.ceil(n / 32);
|
|
const vsize = Math.ceil(m / 32);
|
|
|
|
for (let i = 0; i < hsize; i++) {
|
|
phc[i] = -1;
|
|
mhc[i] = 0;
|
|
}
|
|
|
|
let j = 0;
|
|
|
|
for (; j < vsize - 1; j++) {
|
|
let mv = 0;
|
|
let pv = -1;
|
|
const start = j * 32;
|
|
const vlen = Math.min(32, m) + start;
|
|
|
|
for (let k = start; k < vlen; k++) {
|
|
peq[b.charCodeAt(k)] |= 1 << k;
|
|
}
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const eq = peq[a.charCodeAt(i)];
|
|
const pb = phc[i / 32 | 0] >>> i % 32 & 1;
|
|
const mb = mhc[i / 32 | 0] >>> i % 32 & 1;
|
|
const xv = eq | mv;
|
|
const xh = ((eq | mb) & pv) + pv ^ pv | eq | mb;
|
|
let ph = mv | ~(xh | pv);
|
|
let mh = pv & xh;
|
|
|
|
if (ph >>> 31 ^ pb) {
|
|
phc[i / 32 | 0] ^= 1 << i % 32;
|
|
}
|
|
|
|
if (mh >>> 31 ^ mb) {
|
|
mhc[i / 32 | 0] ^= 1 << i % 32;
|
|
}
|
|
|
|
ph = ph << 1 | pb;
|
|
mh = mh << 1 | mb;
|
|
pv = mh | ~(xv | ph);
|
|
mv = ph & xv;
|
|
}
|
|
|
|
for (let k = start; k < vlen; k++) {
|
|
peq[b.charCodeAt(k)] = 0;
|
|
}
|
|
}
|
|
|
|
let mv = 0;
|
|
let pv = -1;
|
|
const start = j * 32;
|
|
const vlen = Math.min(32, m - start) + start;
|
|
|
|
for (let k = start; k < vlen; k++) {
|
|
peq[b.charCodeAt(k)] |= 1 << k;
|
|
}
|
|
|
|
let score = m;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const eq = peq[a.charCodeAt(i)];
|
|
const pb = phc[i / 32 | 0] >>> i % 32 & 1;
|
|
const mb = mhc[i / 32 | 0] >>> i % 32 & 1;
|
|
const xv = eq | mv;
|
|
const xh = ((eq | mb) & pv) + pv ^ pv | eq | mb;
|
|
let ph = mv | ~(xh | pv);
|
|
let mh = pv & xh;
|
|
score += ph >>> m % 32 - 1 & 1;
|
|
score -= mh >>> m % 32 - 1 & 1;
|
|
|
|
if (ph >>> 31 ^ pb) {
|
|
phc[i / 32 | 0] ^= 1 << i % 32;
|
|
}
|
|
|
|
if (mh >>> 31 ^ mb) {
|
|
mhc[i / 32 | 0] ^= 1 << i % 32;
|
|
}
|
|
|
|
ph = ph << 1 | pb;
|
|
mh = mh << 1 | mb;
|
|
pv = mh | ~(xv | ph);
|
|
mv = ph & xv;
|
|
}
|
|
|
|
for (let k = start; k < vlen; k++) {
|
|
peq[b.charCodeAt(k)] = 0;
|
|
}
|
|
|
|
return score;
|
|
};
|
|
|
|
const distance = (a, b) => {
|
|
if (a.length < b.length) {
|
|
const tmp = b;
|
|
b = a;
|
|
a = tmp;
|
|
}
|
|
|
|
if (b.length === 0) {
|
|
return a.length;
|
|
}
|
|
|
|
if (a.length <= 32) {
|
|
return myers_32(a, b);
|
|
}
|
|
|
|
return myers_x(a, b);
|
|
};
|
|
|
|
const getUsedThreshold = (password, entry, threshold) => {
|
|
const isPasswordToShort = password.length <= entry.length;
|
|
const isThresholdLongerThanPassword = password.length <= threshold;
|
|
const shouldUsePasswordLength = isPasswordToShort || isThresholdLongerThanPassword; // if password is too small use the password length divided by 4 while the threshold needs to be at least 1
|
|
|
|
return shouldUsePasswordLength ? Math.ceil(password.length / 4) : threshold;
|
|
};
|
|
|
|
const findLevenshteinDistance = (password, rankedDictionary, threshold) => {
|
|
let foundDistance = 0;
|
|
const found = Object.keys(rankedDictionary).find(entry => {
|
|
const usedThreshold = getUsedThreshold(password, entry, threshold);
|
|
const foundEntryDistance = distance(password, entry);
|
|
const isInThreshold = foundEntryDistance <= usedThreshold;
|
|
|
|
if (isInThreshold) {
|
|
foundDistance = foundEntryDistance;
|
|
}
|
|
|
|
return isInThreshold;
|
|
});
|
|
|
|
if (found) {
|
|
return {
|
|
levenshteinDistance: foundDistance,
|
|
levenshteinDistanceEntry: found
|
|
};
|
|
}
|
|
|
|
return {};
|
|
};
|
|
|
|
var l33tTable = {
|
|
a: ['4', '@'],
|
|
b: ['8'],
|
|
c: ['(', '{', '[', '<'],
|
|
e: ['3'],
|
|
g: ['6', '9'],
|
|
i: ['1', '!', '|'],
|
|
l: ['1', '|', '7'],
|
|
o: ['0'],
|
|
s: ['$', '5'],
|
|
t: ['+', '7'],
|
|
x: ['%'],
|
|
z: ['2']
|
|
};
|
|
|
|
var translationKeys = {
|
|
warnings: {
|
|
straightRow: 'straightRow',
|
|
keyPattern: 'keyPattern',
|
|
simpleRepeat: 'simpleRepeat',
|
|
extendedRepeat: 'extendedRepeat',
|
|
sequences: 'sequences',
|
|
recentYears: 'recentYears',
|
|
dates: 'dates',
|
|
topTen: 'topTen',
|
|
topHundred: 'topHundred',
|
|
common: 'common',
|
|
similarToCommon: 'similarToCommon',
|
|
wordByItself: 'wordByItself',
|
|
namesByThemselves: 'namesByThemselves',
|
|
commonNames: 'commonNames',
|
|
userInputs: 'userInputs',
|
|
pwned: 'pwned'
|
|
},
|
|
suggestions: {
|
|
l33t: 'l33t',
|
|
reverseWords: 'reverseWords',
|
|
allUppercase: 'allUppercase',
|
|
capitalization: 'capitalization',
|
|
dates: 'dates',
|
|
recentYears: 'recentYears',
|
|
associatedYears: 'associatedYears',
|
|
sequences: 'sequences',
|
|
repeated: 'repeated',
|
|
longerKeyboardPattern: 'longerKeyboardPattern',
|
|
anotherWord: 'anotherWord',
|
|
useWords: 'useWords',
|
|
noNeed: 'noNeed',
|
|
pwned: 'pwned'
|
|
},
|
|
timeEstimation: {
|
|
ltSecond: 'ltSecond',
|
|
second: 'second',
|
|
seconds: 'seconds',
|
|
minute: 'minute',
|
|
minutes: 'minutes',
|
|
hour: 'hour',
|
|
hours: 'hours',
|
|
day: 'day',
|
|
days: 'days',
|
|
month: 'month',
|
|
months: 'months',
|
|
year: 'year',
|
|
years: 'years',
|
|
centuries: 'centuries'
|
|
}
|
|
};
|
|
|
|
class Options {
|
|
constructor() {
|
|
this.matchers = {};
|
|
this.l33tTable = l33tTable;
|
|
this.dictionary = {
|
|
userInputs: []
|
|
};
|
|
this.rankedDictionaries = {};
|
|
this.translations = translationKeys;
|
|
this.graphs = {};
|
|
this.availableGraphs = [];
|
|
this.useLevenshteinDistance = false;
|
|
this.levenshteinThreshold = 2;
|
|
this.setRankedDictionaries();
|
|
}
|
|
|
|
setOptions(options = {}) {
|
|
if (options.l33tTable) {
|
|
this.l33tTable = options.l33tTable;
|
|
}
|
|
|
|
if (options.dictionary) {
|
|
this.dictionary = options.dictionary;
|
|
this.setRankedDictionaries();
|
|
}
|
|
|
|
if (options.translations) {
|
|
this.setTranslations(options.translations);
|
|
}
|
|
|
|
if (options.graphs) {
|
|
this.graphs = options.graphs;
|
|
}
|
|
|
|
if (options.useLevenshteinDistance !== undefined) {
|
|
this.useLevenshteinDistance = options.useLevenshteinDistance;
|
|
}
|
|
|
|
if (options.levenshteinThreshold !== undefined) {
|
|
this.levenshteinThreshold = options.levenshteinThreshold;
|
|
}
|
|
}
|
|
|
|
setTranslations(translations) {
|
|
if (this.checkCustomTranslations(translations)) {
|
|
this.translations = translations;
|
|
} else {
|
|
throw new Error('Invalid translations object fallback to keys');
|
|
}
|
|
}
|
|
|
|
checkCustomTranslations(translations) {
|
|
let valid = true;
|
|
Object.keys(translationKeys).forEach(type => {
|
|
if (type in translations) {
|
|
const translationType = type;
|
|
Object.keys(translationKeys[translationType]).forEach(key => {
|
|
if (!(key in translations[translationType])) {
|
|
valid = false;
|
|
}
|
|
});
|
|
} else {
|
|
valid = false;
|
|
}
|
|
});
|
|
return valid;
|
|
}
|
|
|
|
setRankedDictionaries() {
|
|
const rankedDictionaries = {};
|
|
Object.keys(this.dictionary).forEach(name => {
|
|
rankedDictionaries[name] = this.getRankedDictionary(name);
|
|
});
|
|
this.rankedDictionaries = rankedDictionaries;
|
|
}
|
|
|
|
getRankedDictionary(name) {
|
|
const list = this.dictionary[name];
|
|
|
|
if (name === 'userInputs') {
|
|
const sanitizedInputs = [];
|
|
list.forEach(input => {
|
|
const inputType = typeof input;
|
|
|
|
if (inputType === 'string' || inputType === 'number' || inputType === 'boolean') {
|
|
sanitizedInputs.push(input.toString().toLowerCase());
|
|
}
|
|
});
|
|
return buildRankedDictionary(sanitizedInputs);
|
|
}
|
|
|
|
return buildRankedDictionary(list);
|
|
}
|
|
|
|
extendUserInputsDictionary(dictionary) {
|
|
if (this.dictionary.userInputs) {
|
|
this.dictionary.userInputs = [...this.dictionary.userInputs, ...dictionary];
|
|
} else {
|
|
this.dictionary.userInputs = dictionary;
|
|
}
|
|
|
|
this.rankedDictionaries.userInputs = this.getRankedDictionary('userInputs');
|
|
}
|
|
|
|
addMatcher(name, matcher) {
|
|
if (this.matchers[name]) {
|
|
console.info(`Matcher ${name} already exists`);
|
|
} else {
|
|
this.matchers[name] = matcher;
|
|
}
|
|
}
|
|
|
|
}
|
|
const zxcvbnOptions = new Options();
|
|
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* Dictionary reverse matching --------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
class MatchL33t$1 {
|
|
constructor(defaultMatch) {
|
|
this.defaultMatch = defaultMatch;
|
|
}
|
|
|
|
match({
|
|
password
|
|
}) {
|
|
const passwordReversed = password.split('').reverse().join('');
|
|
return this.defaultMatch({
|
|
password: passwordReversed
|
|
}).map(match => ({ ...match,
|
|
token: match.token.split('').reverse().join(''),
|
|
reversed: true,
|
|
// map coordinates back to original string
|
|
i: password.length - 1 - match.j,
|
|
j: password.length - 1 - match.i
|
|
}));
|
|
}
|
|
|
|
}
|
|
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* Dictionary l33t matching -----------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class MatchL33t {
|
|
constructor(defaultMatch) {
|
|
this.defaultMatch = defaultMatch;
|
|
}
|
|
|
|
match({
|
|
password
|
|
}) {
|
|
const matches = [];
|
|
const enumeratedSubs = this.enumerateL33tSubs(this.relevantL33tSubtable(password, zxcvbnOptions.l33tTable));
|
|
|
|
for (let i = 0; i < enumeratedSubs.length; i += 1) {
|
|
const sub = enumeratedSubs[i]; // corner case: password has no relevant subs.
|
|
|
|
if (empty(sub)) {
|
|
break;
|
|
}
|
|
|
|
const subbedPassword = translate(password, sub);
|
|
const matchedDictionary = this.defaultMatch({
|
|
password: subbedPassword
|
|
});
|
|
matchedDictionary.forEach(match => {
|
|
const token = password.slice(match.i, +match.j + 1 || 9e9); // only return the matches that contain an actual substitution
|
|
|
|
if (token.toLowerCase() !== match.matchedWord) {
|
|
// subset of mappings in sub that are in use for this match
|
|
const matchSub = {};
|
|
Object.keys(sub).forEach(subbedChr => {
|
|
const chr = sub[subbedChr];
|
|
|
|
if (token.indexOf(subbedChr) !== -1) {
|
|
matchSub[subbedChr] = chr;
|
|
}
|
|
});
|
|
const subDisplay = Object.keys(matchSub).map(k => `${k} -> ${matchSub[k]}`).join(', ');
|
|
matches.push({ ...match,
|
|
l33t: true,
|
|
token,
|
|
sub: matchSub,
|
|
subDisplay
|
|
});
|
|
}
|
|
});
|
|
} // filter single-character l33t matches to reduce noise.
|
|
// otherwise '1' matches 'i', '4' matches 'a', both very common English words
|
|
// with low dictionary rank.
|
|
|
|
|
|
return matches.filter(match => match.token.length > 1);
|
|
} // makes a pruned copy of l33t_table that only includes password's possible substitutions
|
|
|
|
|
|
relevantL33tSubtable(password, table) {
|
|
const passwordChars = {};
|
|
const subTable = {};
|
|
password.split('').forEach(char => {
|
|
passwordChars[char] = true;
|
|
});
|
|
Object.keys(table).forEach(letter => {
|
|
const subs = table[letter];
|
|
const relevantSubs = subs.filter(sub => sub in passwordChars);
|
|
|
|
if (relevantSubs.length > 0) {
|
|
subTable[letter] = relevantSubs;
|
|
}
|
|
});
|
|
return subTable;
|
|
} // returns the list of possible 1337 replacement dictionaries for a given password
|
|
|
|
|
|
enumerateL33tSubs(table) {
|
|
const tableKeys = Object.keys(table);
|
|
const subs = this.getSubs(tableKeys, [[]], table); // convert from assoc lists to dicts
|
|
|
|
return subs.map(sub => {
|
|
const subDict = {};
|
|
sub.forEach(([l33tChr, chr]) => {
|
|
subDict[l33tChr] = chr;
|
|
});
|
|
return subDict;
|
|
});
|
|
}
|
|
|
|
getSubs(keys, subs, table) {
|
|
if (!keys.length) {
|
|
return subs;
|
|
}
|
|
|
|
const firstKey = keys[0];
|
|
const restKeys = keys.slice(1);
|
|
const nextSubs = [];
|
|
table[firstKey].forEach(l33tChr => {
|
|
subs.forEach(sub => {
|
|
let dupL33tIndex = -1;
|
|
|
|
for (let i = 0; i < sub.length; i += 1) {
|
|
if (sub[i][0] === l33tChr) {
|
|
dupL33tIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dupL33tIndex === -1) {
|
|
const subExtension = sub.concat([[l33tChr, firstKey]]);
|
|
nextSubs.push(subExtension);
|
|
} else {
|
|
const subAlternative = sub.slice(0);
|
|
subAlternative.splice(dupL33tIndex, 1);
|
|
subAlternative.push([l33tChr, firstKey]);
|
|
nextSubs.push(sub);
|
|
nextSubs.push(subAlternative);
|
|
}
|
|
});
|
|
});
|
|
const newSubs = this.dedup(nextSubs);
|
|
|
|
if (restKeys.length) {
|
|
return this.getSubs(restKeys, newSubs, table);
|
|
}
|
|
|
|
return newSubs;
|
|
}
|
|
|
|
dedup(subs) {
|
|
const deduped = [];
|
|
const members = {};
|
|
subs.forEach(sub => {
|
|
const assoc = sub.map((k, index) => [k, index]);
|
|
assoc.sort();
|
|
const label = assoc.map(([k, v]) => `${k},${v}`).join('-');
|
|
|
|
if (!(label in members)) {
|
|
members[label] = true;
|
|
deduped.push(sub);
|
|
}
|
|
});
|
|
return deduped;
|
|
}
|
|
|
|
}
|
|
|
|
class MatchDictionary {
|
|
constructor() {
|
|
this.l33t = new MatchL33t(this.defaultMatch);
|
|
this.reverse = new MatchL33t$1(this.defaultMatch);
|
|
}
|
|
|
|
match({
|
|
password
|
|
}) {
|
|
const matches = [...this.defaultMatch({
|
|
password
|
|
}), ...this.reverse.match({
|
|
password
|
|
}), ...this.l33t.match({
|
|
password
|
|
})];
|
|
return sorted(matches);
|
|
}
|
|
|
|
defaultMatch({
|
|
password
|
|
}) {
|
|
const matches = [];
|
|
const passwordLength = password.length;
|
|
const passwordLower = password.toLowerCase(); // eslint-disable-next-line complexity
|
|
|
|
Object.keys(zxcvbnOptions.rankedDictionaries).forEach(dictionaryName => {
|
|
const rankedDict = zxcvbnOptions.rankedDictionaries[dictionaryName];
|
|
|
|
for (let i = 0; i < passwordLength; i += 1) {
|
|
for (let j = i; j < passwordLength; j += 1) {
|
|
const usedPassword = passwordLower.slice(i, +j + 1 || 9e9);
|
|
const isInDictionary = (usedPassword in rankedDict);
|
|
let foundLevenshteinDistance = {}; // only use levenshtein distance on full password to minimize the performance drop
|
|
// and because otherwise there would be to many false positives
|
|
|
|
const isFullPassword = i === 0 && j === passwordLength - 1;
|
|
|
|
if (zxcvbnOptions.useLevenshteinDistance && isFullPassword && !isInDictionary) {
|
|
foundLevenshteinDistance = findLevenshteinDistance(usedPassword, rankedDict, zxcvbnOptions.levenshteinThreshold);
|
|
}
|
|
|
|
const isLevenshteinMatch = Object.keys(foundLevenshteinDistance).length !== 0;
|
|
|
|
if (isInDictionary || isLevenshteinMatch) {
|
|
const usedRankPassword = isLevenshteinMatch ? foundLevenshteinDistance.levenshteinDistanceEntry : usedPassword;
|
|
const rank = rankedDict[usedRankPassword];
|
|
matches.push({
|
|
pattern: 'dictionary',
|
|
i,
|
|
j,
|
|
token: password.slice(i, +j + 1 || 9e9),
|
|
matchedWord: usedPassword,
|
|
rank,
|
|
dictionaryName: dictionaryName,
|
|
reversed: false,
|
|
l33t: false,
|
|
...foundLevenshteinDistance
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return matches;
|
|
}
|
|
|
|
}
|
|
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* regex matching ---------------------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class MatchRegex {
|
|
match({
|
|
password,
|
|
regexes = REGEXEN
|
|
}) {
|
|
const matches = [];
|
|
Object.keys(regexes).forEach(name => {
|
|
const regex = regexes[name];
|
|
regex.lastIndex = 0; // keeps regexMatch stateless
|
|
|
|
const regexMatch = regex.exec(password);
|
|
|
|
if (regexMatch) {
|
|
const token = regexMatch[0];
|
|
matches.push({
|
|
pattern: 'regex',
|
|
token,
|
|
i: regexMatch.index,
|
|
j: regexMatch.index + regexMatch[0].length - 1,
|
|
regexName: name,
|
|
regexMatch
|
|
});
|
|
}
|
|
});
|
|
return sorted(matches);
|
|
}
|
|
|
|
}
|
|
|
|
var utils = {
|
|
// binomial coefficients
|
|
// src: http://blog.plover.com/math/choose.html
|
|
nCk(n, k) {
|
|
let count = n;
|
|
|
|
if (k > count) {
|
|
return 0;
|
|
}
|
|
|
|
if (k === 0) {
|
|
return 1;
|
|
}
|
|
|
|
let coEff = 1;
|
|
|
|
for (let i = 1; i <= k; i += 1) {
|
|
coEff *= count;
|
|
coEff /= i;
|
|
count -= 1;
|
|
}
|
|
|
|
return coEff;
|
|
},
|
|
|
|
log10(n) {
|
|
return Math.log(n) / Math.log(10); // IE doesn't support Math.log10 :(
|
|
},
|
|
|
|
log2(n) {
|
|
return Math.log(n) / Math.log(2);
|
|
},
|
|
|
|
factorial(num) {
|
|
let rval = 1;
|
|
|
|
for (let i = 2; i <= num; i += 1) rval *= i;
|
|
|
|
return rval;
|
|
}
|
|
|
|
};
|
|
|
|
var bruteforceMatcher$1 = (({
|
|
token
|
|
}) => {
|
|
let guesses = BRUTEFORCE_CARDINALITY ** token.length;
|
|
|
|
if (guesses === Number.POSITIVE_INFINITY) {
|
|
guesses = Number.MAX_VALUE;
|
|
}
|
|
|
|
let minGuesses; // 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.
|
|
|
|
if (token.length === 1) {
|
|
minGuesses = MIN_SUBMATCH_GUESSES_SINGLE_CHAR + 1;
|
|
} else {
|
|
minGuesses = MIN_SUBMATCH_GUESSES_MULTI_CHAR + 1;
|
|
}
|
|
|
|
return Math.max(guesses, minGuesses);
|
|
});
|
|
|
|
var dateMatcher$1 = (({
|
|
year,
|
|
separator
|
|
}) => {
|
|
// base guesses: (year distance from REFERENCE_YEAR) * num_days * num_years
|
|
const yearSpace = Math.max(Math.abs(year - REFERENCE_YEAR), MIN_YEAR_SPACE);
|
|
let guesses = yearSpace * 365; // add factor of 4 for separator selection (one of ~4 choices)
|
|
|
|
if (separator) {
|
|
guesses *= 4;
|
|
}
|
|
|
|
return guesses;
|
|
});
|
|
|
|
const getVariations = cleanedWord => {
|
|
const wordArray = cleanedWord.split('');
|
|
const upperCaseCount = wordArray.filter(char => char.match(ONE_UPPER)).length;
|
|
const lowerCaseCount = wordArray.filter(char => char.match(ONE_LOWER)).length;
|
|
let variations = 0;
|
|
const variationLength = Math.min(upperCaseCount, lowerCaseCount);
|
|
|
|
for (let i = 1; i <= variationLength; i += 1) {
|
|
variations += utils.nCk(upperCaseCount + lowerCaseCount, i);
|
|
}
|
|
|
|
return variations;
|
|
};
|
|
|
|
var uppercaseVariant = (word => {
|
|
// clean words of non alpha characters to remove the reward effekt to capitalize the first letter https://github.com/dropbox/zxcvbn/issues/232
|
|
const cleanedWord = word.replace(ALPHA_INVERTED, '');
|
|
|
|
if (cleanedWord.match(ALL_LOWER_INVERTED) || cleanedWord.toLowerCase() === cleanedWord) {
|
|
return 1;
|
|
} // a capitalized word is the most common capitalization scheme,
|
|
// so it only doubles the search space (uncapitalized + capitalized).
|
|
// all caps and end-capitalized are common enough too, underestimate as 2x factor to be safe.
|
|
|
|
|
|
const commonCases = [START_UPPER, END_UPPER, ALL_UPPER_INVERTED];
|
|
const commonCasesLength = commonCases.length;
|
|
|
|
for (let i = 0; i < commonCasesLength; i += 1) {
|
|
const regex = commonCases[i];
|
|
|
|
if (cleanedWord.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.
|
|
|
|
|
|
return getVariations(cleanedWord);
|
|
});
|
|
|
|
const getCounts = ({
|
|
subs,
|
|
subbed,
|
|
token
|
|
}) => {
|
|
const unsubbed = subs[subbed]; // lower-case match.token before calculating: capitalization shouldn't affect l33t calc.
|
|
|
|
const chrs = token.toLowerCase().split(''); // num of subbed chars
|
|
|
|
const subbedCount = chrs.filter(char => char === subbed).length; // num of unsubbed chars
|
|
|
|
const unsubbedCount = chrs.filter(char => char === unsubbed).length;
|
|
return {
|
|
subbedCount,
|
|
unsubbedCount
|
|
};
|
|
};
|
|
|
|
var l33tVariant = (({
|
|
l33t,
|
|
sub,
|
|
token
|
|
}) => {
|
|
if (!l33t) {
|
|
return 1;
|
|
}
|
|
|
|
let variations = 1;
|
|
const subs = sub;
|
|
Object.keys(subs).forEach(subbed => {
|
|
const {
|
|
subbedCount,
|
|
unsubbedCount
|
|
} = getCounts({
|
|
subs,
|
|
subbed,
|
|
token
|
|
});
|
|
|
|
if (subbedCount === 0 || unsubbedCount === 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(unsubbedCount, subbedCount);
|
|
let possibilities = 0;
|
|
|
|
for (let i = 1; i <= p; i += 1) {
|
|
possibilities += utils.nCk(unsubbedCount + subbedCount, i);
|
|
}
|
|
|
|
variations *= possibilities;
|
|
}
|
|
});
|
|
return variations;
|
|
});
|
|
|
|
var dictionaryMatcher$1 = (({
|
|
rank,
|
|
reversed,
|
|
l33t,
|
|
sub,
|
|
token
|
|
}) => {
|
|
const baseGuesses = rank; // keep these as properties for display purposes
|
|
|
|
const uppercaseVariations = uppercaseVariant(token);
|
|
const l33tVariations = l33tVariant({
|
|
l33t,
|
|
sub,
|
|
token
|
|
});
|
|
const reversedVariations = reversed && 2 || 1;
|
|
const calculation = baseGuesses * uppercaseVariations * l33tVariations * reversedVariations;
|
|
return {
|
|
baseGuesses,
|
|
uppercaseVariations,
|
|
l33tVariations,
|
|
calculation
|
|
};
|
|
});
|
|
|
|
var regexMatcher$1 = (({
|
|
regexName,
|
|
regexMatch,
|
|
token
|
|
}) => {
|
|
const charClassBases = {
|
|
alphaLower: 26,
|
|
alphaUpper: 26,
|
|
alpha: 52,
|
|
alphanumeric: 62,
|
|
digits: 10,
|
|
symbols: 33
|
|
};
|
|
|
|
if (regexName in charClassBases) {
|
|
return charClassBases[regexName] ** token.length;
|
|
} // TODO add more regex types for example special dates like 09.11
|
|
// eslint-disable-next-line default-case
|
|
|
|
|
|
switch (regexName) {
|
|
case 'recentYear':
|
|
// 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.
|
|
return Math.max(Math.abs(parseInt(regexMatch[0], 10) - REFERENCE_YEAR), MIN_YEAR_SPACE);
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
var repeatMatcher$1 = (({
|
|
baseGuesses,
|
|
repeatCount
|
|
}) => baseGuesses * repeatCount);
|
|
|
|
var sequenceMatcher$1 = (({
|
|
token,
|
|
ascending
|
|
}) => {
|
|
const firstChr = token.charAt(0);
|
|
let baseGuesses = 0;
|
|
const startingPoints = ['a', 'A', 'z', 'Z', '0', '1', '9']; // lower guesses for obvious starting points
|
|
|
|
if (startingPoints.includes(firstChr)) {
|
|
baseGuesses = 4;
|
|
} else if (firstChr.match(/\d/)) {
|
|
baseGuesses = 10; // digits
|
|
} else {
|
|
// could give a higher base for uppercase,
|
|
// assigning 26 to both upper and lower sequences is more conservative.
|
|
baseGuesses = 26;
|
|
} // need to try a descending sequence in addition to every ascending sequence ->
|
|
// 2x guesses
|
|
|
|
|
|
if (!ascending) {
|
|
baseGuesses *= 2;
|
|
}
|
|
|
|
return baseGuesses * token.length;
|
|
});
|
|
|
|
const calcAverageDegree = graph => {
|
|
let average = 0;
|
|
Object.keys(graph).forEach(key => {
|
|
const neighbors = graph[key];
|
|
average += neighbors.filter(entry => !!entry).length;
|
|
});
|
|
average /= Object.entries(graph).length;
|
|
return average;
|
|
};
|
|
|
|
const estimatePossiblePatterns = ({
|
|
token,
|
|
graph,
|
|
turns
|
|
}) => {
|
|
const startingPosition = Object.keys(zxcvbnOptions.graphs[graph]).length;
|
|
const averageDegree = calcAverageDegree(zxcvbnOptions.graphs[graph]);
|
|
let guesses = 0;
|
|
const tokenLength = token.length; // # estimate the number of possible patterns w/ tokenLength or less with turns or less.
|
|
|
|
for (let i = 2; i <= tokenLength; i += 1) {
|
|
const possibleTurns = Math.min(turns, i - 1);
|
|
|
|
for (let j = 1; j <= possibleTurns; j += 1) {
|
|
guesses += utils.nCk(i - 1, j - 1) * startingPosition * averageDegree ** j;
|
|
}
|
|
}
|
|
|
|
return guesses;
|
|
};
|
|
|
|
var spatialMatcher$1 = (({
|
|
graph,
|
|
token,
|
|
shiftedCount,
|
|
turns
|
|
}) => {
|
|
let guesses = estimatePossiblePatterns({
|
|
token,
|
|
graph,
|
|
turns
|
|
}); // 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 (shiftedCount) {
|
|
const unShiftedCount = token.length - shiftedCount;
|
|
|
|
if (shiftedCount === 0 || unShiftedCount === 0) {
|
|
guesses *= 2;
|
|
} else {
|
|
let shiftedVariations = 0;
|
|
|
|
for (let i = 1; i <= Math.min(shiftedCount, unShiftedCount); i += 1) {
|
|
shiftedVariations += utils.nCk(shiftedCount + unShiftedCount, i);
|
|
}
|
|
|
|
guesses *= shiftedVariations;
|
|
}
|
|
}
|
|
|
|
return Math.round(guesses);
|
|
});
|
|
|
|
const getMinGuesses = (match, password) => {
|
|
let minGuesses = 1;
|
|
|
|
if (match.token.length < password.length) {
|
|
if (match.token.length === 1) {
|
|
minGuesses = MIN_SUBMATCH_GUESSES_SINGLE_CHAR;
|
|
} else {
|
|
minGuesses = MIN_SUBMATCH_GUESSES_MULTI_CHAR;
|
|
}
|
|
}
|
|
|
|
return minGuesses;
|
|
};
|
|
|
|
const matchers = {
|
|
bruteforce: bruteforceMatcher$1,
|
|
date: dateMatcher$1,
|
|
dictionary: dictionaryMatcher$1,
|
|
regex: regexMatcher$1,
|
|
repeat: repeatMatcher$1,
|
|
sequence: sequenceMatcher$1,
|
|
spatial: spatialMatcher$1
|
|
};
|
|
|
|
const getScoring = (name, match) => {
|
|
if (matchers[name]) {
|
|
return matchers[name](match);
|
|
}
|
|
|
|
if (zxcvbnOptions.matchers[name] && 'scoring' in zxcvbnOptions.matchers[name]) {
|
|
return zxcvbnOptions.matchers[name].scoring(match);
|
|
}
|
|
|
|
return 0;
|
|
}; // ------------------------------------------------------------------------------
|
|
// guess estimation -- one function per match pattern ---------------------------
|
|
// ------------------------------------------------------------------------------
|
|
|
|
|
|
var estimateGuesses = ((match, password) => {
|
|
const extraData = {}; // a match's guess estimate doesn't change. cache it.
|
|
|
|
if ('guesses' in match && match.guesses != null) {
|
|
return match;
|
|
}
|
|
|
|
const minGuesses = getMinGuesses(match, password);
|
|
const estimationResult = getScoring(match.pattern, match);
|
|
let guesses = 0;
|
|
|
|
if (typeof estimationResult === 'number') {
|
|
guesses = estimationResult;
|
|
} else if (match.pattern === 'dictionary') {
|
|
guesses = estimationResult.calculation;
|
|
extraData.baseGuesses = estimationResult.baseGuesses;
|
|
extraData.uppercaseVariations = estimationResult.uppercaseVariations;
|
|
extraData.l33tVariations = estimationResult.l33tVariations;
|
|
}
|
|
|
|
const matchGuesses = Math.max(guesses, minGuesses);
|
|
return { ...match,
|
|
...extraData,
|
|
guesses: matchGuesses,
|
|
guessesLog10: utils.log10(matchGuesses)
|
|
};
|
|
});
|
|
|
|
const scoringHelper = {
|
|
password: '',
|
|
optimal: {},
|
|
excludeAdditive: false,
|
|
|
|
fillArray(size, valueType) {
|
|
const result = [];
|
|
|
|
for (let i = 0; i < size; i += 1) {
|
|
let value = [];
|
|
|
|
if (valueType === 'object') {
|
|
value = {};
|
|
}
|
|
|
|
result.push(value);
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
// helper: make bruteforce match objects spanning i to j, inclusive.
|
|
makeBruteforceMatch(i, j) {
|
|
return {
|
|
pattern: 'bruteforce',
|
|
token: this.password.slice(i, +j + 1 || 9e9),
|
|
i,
|
|
j
|
|
};
|
|
},
|
|
|
|
// helper: considers whether a length-sequenceLength
|
|
// sequence ending at match m is better (fewer guesses)
|
|
// than previously encountered sequences, updating state if so.
|
|
update(match, sequenceLength) {
|
|
const k = match.j;
|
|
const estimatedMatch = estimateGuesses(match, this.password);
|
|
let pi = estimatedMatch.guesses;
|
|
|
|
if (sequenceLength > 1) {
|
|
// we're considering a length-sequenceLength sequence ending with match m:
|
|
// obtain the product term in the minimization function by multiplying m's guesses
|
|
// by the product of the length-(sequenceLength-1)
|
|
// sequence ending just before m, at m.i - 1.
|
|
pi *= this.optimal.pi[estimatedMatch.i - 1][sequenceLength - 1];
|
|
} // calculate the minimization func
|
|
|
|
|
|
let g = utils.factorial(sequenceLength) * pi;
|
|
|
|
if (!this.excludeAdditive) {
|
|
g += MIN_GUESSES_BEFORE_GROWING_SEQUENCE ** (sequenceLength - 1);
|
|
} // update state if new best.
|
|
// first see if any competing sequences covering this prefix,
|
|
// with sequenceLength or fewer matches,
|
|
// fare better than this sequence. if so, skip it and return.
|
|
|
|
|
|
let shouldSkip = false;
|
|
Object.keys(this.optimal.g[k]).forEach(competingPatternLength => {
|
|
const competingMetricMatch = this.optimal.g[k][competingPatternLength];
|
|
|
|
if (parseInt(competingPatternLength, 10) <= sequenceLength) {
|
|
if (competingMetricMatch <= g) {
|
|
shouldSkip = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!shouldSkip) {
|
|
// this sequence might be part of the final optimal sequence.
|
|
this.optimal.g[k][sequenceLength] = g;
|
|
this.optimal.m[k][sequenceLength] = estimatedMatch;
|
|
this.optimal.pi[k][sequenceLength] = pi;
|
|
}
|
|
},
|
|
|
|
// helper: evaluate bruteforce matches ending at passwordCharIndex.
|
|
bruteforceUpdate(passwordCharIndex) {
|
|
// see if a single bruteforce match spanning the passwordCharIndex-prefix is optimal.
|
|
let match = this.makeBruteforceMatch(0, passwordCharIndex);
|
|
this.update(match, 1);
|
|
|
|
for (let i = 1; i <= passwordCharIndex; i += 1) {
|
|
// generate passwordCharIndex bruteforce matches, spanning from (i=1, j=passwordCharIndex) up to (i=passwordCharIndex, j=passwordCharIndex).
|
|
// see if adding these new matches to any of the sequences in optimal[i-1]
|
|
// leads to new bests.
|
|
match = this.makeBruteforceMatch(i, passwordCharIndex);
|
|
const tmp = this.optimal.m[i - 1]; // eslint-disable-next-line no-loop-func
|
|
|
|
Object.keys(tmp).forEach(sequenceLength => {
|
|
const lastMatch = tmp[sequenceLength]; // 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 (lastMatch.pattern !== 'bruteforce') {
|
|
// try adding m to this length-sequenceLength sequence.
|
|
this.update(match, parseInt(sequenceLength, 10) + 1);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
// helper: step backwards through optimal.m starting at the end,
|
|
// constructing the final optimal match sequence.
|
|
unwind(passwordLength) {
|
|
const optimalMatchSequence = [];
|
|
let k = passwordLength - 1; // find the final best sequence length and score
|
|
|
|
let sequenceLength = 0; // eslint-disable-next-line no-loss-of-precision
|
|
|
|
let g = 2e308;
|
|
const temp = this.optimal.g[k]; // safety check for empty passwords
|
|
|
|
if (temp) {
|
|
Object.keys(temp).forEach(candidateSequenceLength => {
|
|
const candidateMetricMatch = temp[candidateSequenceLength];
|
|
|
|
if (candidateMetricMatch < g) {
|
|
sequenceLength = parseInt(candidateSequenceLength, 10);
|
|
g = candidateMetricMatch;
|
|
}
|
|
});
|
|
}
|
|
|
|
while (k >= 0) {
|
|
const match = this.optimal.m[k][sequenceLength];
|
|
optimalMatchSequence.unshift(match);
|
|
k = match.i - 1;
|
|
sequenceLength -= 1;
|
|
}
|
|
|
|
return optimalMatchSequence;
|
|
}
|
|
|
|
};
|
|
var scoring = {
|
|
// ------------------------------------------------------------------------------
|
|
// 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 = sequenceLength! * Product(m.guesses for m in sequence) + D^(sequenceLength - 1)
|
|
//
|
|
// where sequenceLength is the length of the sequence.
|
|
//
|
|
// the factorial term is the number of ways to order sequenceLength patterns.
|
|
//
|
|
// the D^(sequenceLength-1) term is another length penalty, roughly capturing the idea that an
|
|
// attacker will try lower-length sequences first before trying length-sequenceLength 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^(sequenceLength-1) approximates Sum(D^i for i in [1..sequenceLength-1]
|
|
//
|
|
// ------------------------------------------------------------------------------
|
|
mostGuessableMatchSequence(password, matches, excludeAdditive = false) {
|
|
scoringHelper.password = password;
|
|
scoringHelper.excludeAdditive = excludeAdditive;
|
|
const passwordLength = password.length; // partition matches into sublists according to ending index j
|
|
|
|
let matchesByCoordinateJ = scoringHelper.fillArray(passwordLength, 'array');
|
|
matches.forEach(match => {
|
|
matchesByCoordinateJ[match.j].push(match);
|
|
}); // small detail: for deterministic output, sort each sublist by i.
|
|
|
|
matchesByCoordinateJ = matchesByCoordinateJ.map(match => match.sort((m1, m2) => m1.i - m2.i));
|
|
scoringHelper.optimal = {
|
|
// optimal.m[k][sequenceLength] holds final match in the best length-sequenceLength
|
|
// match sequence covering the
|
|
// password prefix up to k, inclusive.
|
|
// if there is no length-sequenceLength sequence that scores better (fewer guesses) than
|
|
// a shorter match sequence spanning the same prefix,
|
|
// optimal.m[k][sequenceLength] is undefined.
|
|
m: scoringHelper.fillArray(passwordLength, 'object'),
|
|
// 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: scoringHelper.fillArray(passwordLength, 'object'),
|
|
// same structure as optimal.m -- holds the overall metric.
|
|
g: scoringHelper.fillArray(passwordLength, 'object')
|
|
};
|
|
|
|
for (let k = 0; k < passwordLength; k += 1) {
|
|
matchesByCoordinateJ[k].forEach(match => {
|
|
if (match.i > 0) {
|
|
Object.keys(scoringHelper.optimal.m[match.i - 1]).forEach(sequenceLength => {
|
|
scoringHelper.update(match, parseInt(sequenceLength, 10) + 1);
|
|
});
|
|
} else {
|
|
scoringHelper.update(match, 1);
|
|
}
|
|
});
|
|
scoringHelper.bruteforceUpdate(k);
|
|
}
|
|
|
|
const optimalMatchSequence = scoringHelper.unwind(passwordLength);
|
|
const optimalSequenceLength = optimalMatchSequence.length;
|
|
const guesses = this.getGuesses(password, optimalSequenceLength);
|
|
return {
|
|
password,
|
|
guesses,
|
|
guessesLog10: utils.log10(guesses),
|
|
sequence: optimalMatchSequence
|
|
};
|
|
},
|
|
|
|
getGuesses(password, optimalSequenceLength) {
|
|
const passwordLength = password.length;
|
|
let guesses = 0;
|
|
|
|
if (password.length === 0) {
|
|
guesses = 1;
|
|
} else {
|
|
guesses = scoringHelper.optimal.g[passwordLength - 1][optimalSequenceLength];
|
|
}
|
|
|
|
return guesses;
|
|
}
|
|
|
|
};
|
|
|
|
/*
|
|
*-------------------------------------------------------------------------------
|
|
* repeats (aaa, abcabcabc) ------------------------------
|
|
*-------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class MatchRepeat {
|
|
// eslint-disable-next-line max-statements
|
|
match({
|
|
password,
|
|
omniMatch
|
|
}) {
|
|
const matches = [];
|
|
let lastIndex = 0;
|
|
|
|
while (lastIndex < password.length) {
|
|
const greedyMatch = this.getGreedyMatch(password, lastIndex);
|
|
const lazyMatch = this.getLazyMatch(password, lastIndex);
|
|
|
|
if (greedyMatch == null) {
|
|
break;
|
|
}
|
|
|
|
const {
|
|
match,
|
|
baseToken
|
|
} = this.setMatchToken(greedyMatch, lazyMatch);
|
|
|
|
if (match) {
|
|
const j = match.index + match[0].length - 1;
|
|
const baseGuesses = this.getBaseGuesses(baseToken, omniMatch);
|
|
matches.push(this.normalizeMatch(baseToken, j, match, baseGuesses));
|
|
lastIndex = j + 1;
|
|
}
|
|
}
|
|
|
|
const hasPromises = matches.some(match => {
|
|
return match instanceof Promise;
|
|
});
|
|
|
|
if (hasPromises) {
|
|
return Promise.all(matches);
|
|
}
|
|
|
|
return matches;
|
|
} // eslint-disable-next-line max-params
|
|
|
|
|
|
normalizeMatch(baseToken, j, match, baseGuesses) {
|
|
const baseMatch = {
|
|
pattern: 'repeat',
|
|
i: match.index,
|
|
j,
|
|
token: match[0],
|
|
baseToken,
|
|
baseGuesses: 0,
|
|
repeatCount: match[0].length / baseToken.length
|
|
};
|
|
|
|
if (baseGuesses instanceof Promise) {
|
|
return baseGuesses.then(resolvedBaseGuesses => {
|
|
return { ...baseMatch,
|
|
baseGuesses: resolvedBaseGuesses
|
|
};
|
|
});
|
|
}
|
|
|
|
return { ...baseMatch,
|
|
baseGuesses
|
|
};
|
|
}
|
|
|
|
getGreedyMatch(password, lastIndex) {
|
|
const greedy = /(.+)\1+/g;
|
|
greedy.lastIndex = lastIndex;
|
|
return greedy.exec(password);
|
|
}
|
|
|
|
getLazyMatch(password, lastIndex) {
|
|
const lazy = /(.+?)\1+/g;
|
|
lazy.lastIndex = lastIndex;
|
|
return lazy.exec(password);
|
|
}
|
|
|
|
setMatchToken(greedyMatch, lazyMatch) {
|
|
const lazyAnchored = /^(.+?)\1+$/;
|
|
let match;
|
|
let baseToken = '';
|
|
|
|
if (lazyMatch && greedyMatch[0].length > lazyMatch[0].length) {
|
|
// greedy beats lazy for 'aabaab'
|
|
// greedy: [aabaab, aab]
|
|
// lazy: [aa, a]
|
|
match = greedyMatch; // 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
|
|
|
|
const temp = lazyAnchored.exec(match[0]);
|
|
|
|
if (temp) {
|
|
baseToken = temp[1];
|
|
}
|
|
} else {
|
|
// lazy beats greedy for 'aaaaa'
|
|
// greedy: [aaaa, aa]
|
|
// lazy: [aaaaa, a]
|
|
match = lazyMatch;
|
|
|
|
if (match) {
|
|
baseToken = match[1];
|
|
}
|
|
}
|
|
|
|
return {
|
|
match,
|
|
baseToken
|
|
};
|
|
}
|
|
|
|
getBaseGuesses(baseToken, omniMatch) {
|
|
const matches = omniMatch.match(baseToken);
|
|
|
|
if (matches instanceof Promise) {
|
|
return matches.then(resolvedMatches => {
|
|
const baseAnalysis = scoring.mostGuessableMatchSequence(baseToken, resolvedMatches);
|
|
return baseAnalysis.guesses;
|
|
});
|
|
}
|
|
|
|
const baseAnalysis = scoring.mostGuessableMatchSequence(baseToken, matches);
|
|
return baseAnalysis.guesses;
|
|
}
|
|
|
|
}
|
|
|
|
/*
|
|
*-------------------------------------------------------------------------------
|
|
* sequences (abcdef) ------------------------------
|
|
*-------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class MatchSequence {
|
|
constructor() {
|
|
this.MAX_DELTA = 5;
|
|
} // eslint-disable-next-line max-statements
|
|
|
|
|
|
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)]
|
|
*/
|
|
const result = [];
|
|
|
|
if (password.length === 1) {
|
|
return [];
|
|
}
|
|
|
|
let i = 0;
|
|
let lastDelta = null;
|
|
const passwordLength = password.length;
|
|
|
|
for (let k = 1; k < passwordLength; k += 1) {
|
|
const delta = password.charCodeAt(k) - password.charCodeAt(k - 1);
|
|
|
|
if (lastDelta == null) {
|
|
lastDelta = delta;
|
|
}
|
|
|
|
if (delta !== lastDelta) {
|
|
const j = k - 1;
|
|
this.update({
|
|
i,
|
|
j,
|
|
delta: lastDelta,
|
|
password,
|
|
result
|
|
});
|
|
i = j;
|
|
lastDelta = delta;
|
|
}
|
|
}
|
|
|
|
this.update({
|
|
i,
|
|
j: passwordLength - 1,
|
|
delta: lastDelta,
|
|
password,
|
|
result
|
|
});
|
|
return result;
|
|
}
|
|
|
|
update({
|
|
i,
|
|
j,
|
|
delta,
|
|
password,
|
|
result
|
|
}) {
|
|
if (j - i > 1 || Math.abs(delta) === 1) {
|
|
const absoluteDelta = Math.abs(delta);
|
|
|
|
if (absoluteDelta > 0 && absoluteDelta <= this.MAX_DELTA) {
|
|
const token = password.slice(i, +j + 1 || 9e9);
|
|
const {
|
|
sequenceName,
|
|
sequenceSpace
|
|
} = this.getSequence(token);
|
|
return result.push({
|
|
pattern: 'sequence',
|
|
i,
|
|
j,
|
|
token: password.slice(i, +j + 1 || 9e9),
|
|
sequenceName,
|
|
sequenceSpace,
|
|
ascending: delta > 0
|
|
});
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
getSequence(token) {
|
|
// TODO conservatively stick with roman alphabet size.
|
|
// (this could be improved)
|
|
let sequenceName = 'unicode';
|
|
let sequenceSpace = 26;
|
|
|
|
if (ALL_LOWER.test(token)) {
|
|
sequenceName = 'lower';
|
|
sequenceSpace = 26;
|
|
} else if (ALL_UPPER.test(token)) {
|
|
sequenceName = 'upper';
|
|
sequenceSpace = 26;
|
|
} else if (ALL_DIGIT.test(token)) {
|
|
sequenceName = 'digits';
|
|
sequenceSpace = 10;
|
|
}
|
|
|
|
return {
|
|
sequenceName,
|
|
sequenceSpace
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
/*
|
|
* ------------------------------------------------------------------------------
|
|
* spatial match (qwerty/dvorak/keypad and so on) -----------------------------------------
|
|
* ------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class MatchSpatial {
|
|
constructor() {
|
|
this.SHIFTED_RX = /[~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?]/;
|
|
}
|
|
|
|
match({
|
|
password
|
|
}) {
|
|
const matches = [];
|
|
Object.keys(zxcvbnOptions.graphs).forEach(graphName => {
|
|
const graph = zxcvbnOptions.graphs[graphName];
|
|
extend(matches, this.helper(password, graph, graphName));
|
|
});
|
|
return sorted(matches);
|
|
}
|
|
|
|
checkIfShifted(graphName, password, index) {
|
|
if (!graphName.includes('keypad') && // initial character is shifted
|
|
this.SHIFTED_RX.test(password.charAt(index))) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
} // eslint-disable-next-line complexity, max-statements
|
|
|
|
|
|
helper(password, graph, graphName) {
|
|
let shiftedCount;
|
|
const matches = [];
|
|
let i = 0;
|
|
const passwordLength = password.length;
|
|
|
|
while (i < passwordLength - 1) {
|
|
let j = i + 1;
|
|
let lastDirection = 0;
|
|
let turns = 0;
|
|
shiftedCount = this.checkIfShifted(graphName, password, i); // eslint-disable-next-line no-constant-condition
|
|
|
|
while (true) {
|
|
const prevChar = password.charAt(j - 1);
|
|
const adjacents = graph[prevChar] || [];
|
|
let found = false;
|
|
let foundDirection = -1;
|
|
let curDirection = -1; // consider growing pattern by one character if j hasn't gone over the edge.
|
|
|
|
if (j < passwordLength) {
|
|
const curChar = password.charAt(j);
|
|
const adjacentsLength = adjacents.length;
|
|
|
|
for (let k = 0; k < adjacentsLength; k += 1) {
|
|
const adjacent = adjacents[k];
|
|
curDirection += 1; // eslint-disable-next-line max-depth
|
|
|
|
if (adjacent) {
|
|
const adjacentIndex = adjacent.indexOf(curChar); // eslint-disable-next-line max-depth
|
|
|
|
if (adjacentIndex !== -1) {
|
|
found = true;
|
|
foundDirection = curDirection; // eslint-disable-next-line max-depth
|
|
|
|
if (adjacentIndex === 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.
|
|
shiftedCount += 1;
|
|
} // eslint-disable-next-line max-depth
|
|
|
|
|
|
if (lastDirection !== foundDirection) {
|
|
// # adding a turn is correct even in the initial
|
|
// case when last_direction is null:
|
|
// # every spatial pattern starts with a turn.
|
|
turns += 1;
|
|
lastDirection = foundDirection;
|
|
}
|
|
|
|
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 {
|
|
// don't consider length 1 or 2 chains.
|
|
if (j - i > 2) {
|
|
matches.push({
|
|
pattern: 'spatial',
|
|
i,
|
|
j: j - 1,
|
|
token: password.slice(i, j),
|
|
graph: graphName,
|
|
turns,
|
|
shiftedCount
|
|
});
|
|
} // ...and then start a new search for the rest of the password.
|
|
|
|
|
|
i = j;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
}
|
|
|
|
class Matching {
|
|
constructor() {
|
|
this.matchers = {
|
|
date: MatchDate,
|
|
dictionary: MatchDictionary,
|
|
regex: MatchRegex,
|
|
// @ts-ignore => TODO resolve this type issue. This is because it is possible to be async
|
|
repeat: MatchRepeat,
|
|
sequence: MatchSequence,
|
|
spatial: MatchSpatial
|
|
};
|
|
}
|
|
|
|
match(password) {
|
|
const matches = [];
|
|
const promises = [];
|
|
const matchers = [...Object.keys(this.matchers), ...Object.keys(zxcvbnOptions.matchers)];
|
|
matchers.forEach(key => {
|
|
if (!this.matchers[key] && !zxcvbnOptions.matchers[key]) {
|
|
return;
|
|
}
|
|
|
|
const Matcher = this.matchers[key] ? this.matchers[key] : zxcvbnOptions.matchers[key].Matching;
|
|
const usedMatcher = new Matcher();
|
|
const result = usedMatcher.match({
|
|
password,
|
|
omniMatch: this
|
|
});
|
|
|
|
if (result instanceof Promise) {
|
|
result.then(response => {
|
|
extend(matches, response);
|
|
});
|
|
promises.push(result);
|
|
} else {
|
|
extend(matches, result);
|
|
}
|
|
});
|
|
|
|
if (promises.length > 0) {
|
|
return new Promise(resolve => {
|
|
Promise.all(promises).then(() => {
|
|
resolve(sorted(matches));
|
|
});
|
|
});
|
|
}
|
|
|
|
return sorted(matches);
|
|
}
|
|
|
|
}
|
|
|
|
const SECOND = 1;
|
|
const MINUTE = SECOND * 60;
|
|
const HOUR = MINUTE * 60;
|
|
const DAY = HOUR * 24;
|
|
const MONTH = DAY * 31;
|
|
const YEAR = MONTH * 12;
|
|
const CENTURY = YEAR * 100;
|
|
const times = {
|
|
second: SECOND,
|
|
minute: MINUTE,
|
|
hour: HOUR,
|
|
day: DAY,
|
|
month: MONTH,
|
|
year: YEAR,
|
|
century: CENTURY
|
|
};
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* Estimates time for an attacker ---------------------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class TimeEstimates {
|
|
translate(displayStr, value) {
|
|
let key = displayStr;
|
|
|
|
if (value !== undefined && value !== 1) {
|
|
key += 's';
|
|
}
|
|
|
|
const {
|
|
timeEstimation
|
|
} = zxcvbnOptions.translations;
|
|
return timeEstimation[key].replace('{base}', `${value}`);
|
|
}
|
|
|
|
estimateAttackTimes(guesses) {
|
|
const crackTimesSeconds = {
|
|
onlineThrottling100PerHour: guesses / (100 / 3600),
|
|
onlineNoThrottling10PerSecond: guesses / 10,
|
|
offlineSlowHashing1e4PerSecond: guesses / 1e4,
|
|
offlineFastHashing1e10PerSecond: guesses / 1e10
|
|
};
|
|
const crackTimesDisplay = {
|
|
onlineThrottling100PerHour: '',
|
|
onlineNoThrottling10PerSecond: '',
|
|
offlineSlowHashing1e4PerSecond: '',
|
|
offlineFastHashing1e10PerSecond: ''
|
|
};
|
|
Object.keys(crackTimesSeconds).forEach(scenario => {
|
|
const seconds = crackTimesSeconds[scenario];
|
|
crackTimesDisplay[scenario] = this.displayTime(seconds);
|
|
});
|
|
return {
|
|
crackTimesSeconds,
|
|
crackTimesDisplay,
|
|
score: this.guessesToScore(guesses)
|
|
};
|
|
}
|
|
|
|
guessesToScore(guesses) {
|
|
const DELTA = 5;
|
|
|
|
if (guesses < 1e3 + DELTA) {
|
|
// risky password: "too guessable"
|
|
return 0;
|
|
}
|
|
|
|
if (guesses < 1e6 + DELTA) {
|
|
// modest protection from throttled online attacks: "very guessable"
|
|
return 1;
|
|
}
|
|
|
|
if (guesses < 1e8 + DELTA) {
|
|
// modest protection from unthrottled online attacks: "somewhat guessable"
|
|
return 2;
|
|
}
|
|
|
|
if (guesses < 1e10 + DELTA) {
|
|
// modest protection from offline attacks: "safely unguessable"
|
|
// assuming a salted, slow hash function like bcrypt, scrypt, PBKDF2, argon, etc
|
|
return 3;
|
|
} // strong protection from offline attacks under same scenario: "very unguessable"
|
|
|
|
|
|
return 4;
|
|
}
|
|
|
|
displayTime(seconds) {
|
|
let displayStr = 'centuries';
|
|
let base;
|
|
const timeKeys = Object.keys(times);
|
|
const foundIndex = timeKeys.findIndex(time => seconds < times[time]);
|
|
|
|
if (foundIndex > -1) {
|
|
displayStr = timeKeys[foundIndex - 1];
|
|
|
|
if (foundIndex !== 0) {
|
|
base = Math.round(seconds / times[displayStr]);
|
|
} else {
|
|
displayStr = 'ltSecond';
|
|
}
|
|
}
|
|
|
|
return this.translate(displayStr, base);
|
|
}
|
|
|
|
}
|
|
|
|
var bruteforceMatcher = (() => {
|
|
return null;
|
|
});
|
|
|
|
var dateMatcher = (() => {
|
|
return {
|
|
warning: zxcvbnOptions.translations.warnings.dates,
|
|
suggestions: [zxcvbnOptions.translations.suggestions.dates]
|
|
};
|
|
});
|
|
|
|
const getDictionaryWarningPassword = (match, isSoleMatch) => {
|
|
let warning = '';
|
|
|
|
if (isSoleMatch && !match.l33t && !match.reversed) {
|
|
if (match.rank <= 10) {
|
|
warning = zxcvbnOptions.translations.warnings.topTen;
|
|
} else if (match.rank <= 100) {
|
|
warning = zxcvbnOptions.translations.warnings.topHundred;
|
|
} else {
|
|
warning = zxcvbnOptions.translations.warnings.common;
|
|
}
|
|
} else if (match.guessesLog10 <= 4) {
|
|
warning = zxcvbnOptions.translations.warnings.similarToCommon;
|
|
}
|
|
|
|
return warning;
|
|
};
|
|
|
|
const getDictionaryWarningWikipedia = (match, isSoleMatch) => {
|
|
let warning = '';
|
|
|
|
if (isSoleMatch) {
|
|
warning = zxcvbnOptions.translations.warnings.wordByItself;
|
|
}
|
|
|
|
return warning;
|
|
};
|
|
|
|
const getDictionaryWarningNames = (match, isSoleMatch) => {
|
|
if (isSoleMatch) {
|
|
return zxcvbnOptions.translations.warnings.namesByThemselves;
|
|
}
|
|
|
|
return zxcvbnOptions.translations.warnings.commonNames;
|
|
};
|
|
|
|
const getDictionaryWarning = (match, isSoleMatch) => {
|
|
let warning = '';
|
|
const dictName = match.dictionaryName;
|
|
const isAName = dictName === 'lastnames' || dictName.toLowerCase().includes('firstnames');
|
|
|
|
if (dictName === 'passwords') {
|
|
warning = getDictionaryWarningPassword(match, isSoleMatch);
|
|
} else if (dictName.includes('wikipedia')) {
|
|
warning = getDictionaryWarningWikipedia(match, isSoleMatch);
|
|
} else if (isAName) {
|
|
warning = getDictionaryWarningNames(match, isSoleMatch);
|
|
} else if (dictName === 'userInputs') {
|
|
warning = zxcvbnOptions.translations.warnings.userInputs;
|
|
}
|
|
|
|
return warning;
|
|
};
|
|
|
|
var dictionaryMatcher = ((match, isSoleMatch) => {
|
|
const warning = getDictionaryWarning(match, isSoleMatch);
|
|
const suggestions = [];
|
|
const word = match.token;
|
|
|
|
if (word.match(START_UPPER)) {
|
|
suggestions.push(zxcvbnOptions.translations.suggestions.capitalization);
|
|
} else if (word.match(ALL_UPPER_INVERTED) && word.toLowerCase() !== word) {
|
|
suggestions.push(zxcvbnOptions.translations.suggestions.allUppercase);
|
|
}
|
|
|
|
if (match.reversed && match.token.length >= 4) {
|
|
suggestions.push(zxcvbnOptions.translations.suggestions.reverseWords);
|
|
}
|
|
|
|
if (match.l33t) {
|
|
suggestions.push(zxcvbnOptions.translations.suggestions.l33t);
|
|
}
|
|
|
|
return {
|
|
warning,
|
|
suggestions
|
|
};
|
|
});
|
|
|
|
var regexMatcher = (match => {
|
|
if (match.regexName === 'recentYear') {
|
|
return {
|
|
warning: zxcvbnOptions.translations.warnings.recentYears,
|
|
suggestions: [zxcvbnOptions.translations.suggestions.recentYears, zxcvbnOptions.translations.suggestions.associatedYears]
|
|
};
|
|
}
|
|
|
|
return {
|
|
warning: '',
|
|
suggestions: []
|
|
};
|
|
});
|
|
|
|
var repeatMatcher = (match => {
|
|
let warning = zxcvbnOptions.translations.warnings.extendedRepeat;
|
|
|
|
if (match.baseToken.length === 1) {
|
|
warning = zxcvbnOptions.translations.warnings.simpleRepeat;
|
|
}
|
|
|
|
return {
|
|
warning,
|
|
suggestions: [zxcvbnOptions.translations.suggestions.repeated]
|
|
};
|
|
});
|
|
|
|
var sequenceMatcher = (() => {
|
|
return {
|
|
warning: zxcvbnOptions.translations.warnings.sequences,
|
|
suggestions: [zxcvbnOptions.translations.suggestions.sequences]
|
|
};
|
|
});
|
|
|
|
var spatialMatcher = (match => {
|
|
let warning = zxcvbnOptions.translations.warnings.keyPattern;
|
|
|
|
if (match.turns === 1) {
|
|
warning = zxcvbnOptions.translations.warnings.straightRow;
|
|
}
|
|
|
|
return {
|
|
warning,
|
|
suggestions: [zxcvbnOptions.translations.suggestions.longerKeyboardPattern]
|
|
};
|
|
});
|
|
|
|
const defaultFeedback = {
|
|
warning: '',
|
|
suggestions: []
|
|
};
|
|
/*
|
|
* -------------------------------------------------------------------------------
|
|
* Generate feedback ---------------------------------------------------------------
|
|
* -------------------------------------------------------------------------------
|
|
*/
|
|
|
|
class Feedback {
|
|
constructor() {
|
|
this.matchers = {
|
|
bruteforce: bruteforceMatcher,
|
|
date: dateMatcher,
|
|
dictionary: dictionaryMatcher,
|
|
regex: regexMatcher,
|
|
repeat: repeatMatcher,
|
|
sequence: sequenceMatcher,
|
|
spatial: spatialMatcher
|
|
};
|
|
this.defaultFeedback = {
|
|
warning: '',
|
|
suggestions: []
|
|
};
|
|
this.setDefaultSuggestions();
|
|
}
|
|
|
|
setDefaultSuggestions() {
|
|
this.defaultFeedback.suggestions.push(zxcvbnOptions.translations.suggestions.useWords, zxcvbnOptions.translations.suggestions.noNeed);
|
|
}
|
|
|
|
getFeedback(score, sequence) {
|
|
if (sequence.length === 0) {
|
|
return this.defaultFeedback;
|
|
}
|
|
|
|
if (score > 2) {
|
|
return defaultFeedback;
|
|
}
|
|
|
|
const extraFeedback = zxcvbnOptions.translations.suggestions.anotherWord;
|
|
const longestMatch = this.getLongestMatch(sequence);
|
|
let feedback = this.getMatchFeedback(longestMatch, sequence.length === 1);
|
|
|
|
if (feedback !== null && feedback !== undefined) {
|
|
feedback.suggestions.unshift(extraFeedback);
|
|
|
|
if (feedback.warning == null) {
|
|
feedback.warning = '';
|
|
}
|
|
} else {
|
|
feedback = {
|
|
warning: '',
|
|
suggestions: [extraFeedback]
|
|
};
|
|
}
|
|
|
|
return feedback;
|
|
}
|
|
|
|
getLongestMatch(sequence) {
|
|
let longestMatch = sequence[0];
|
|
const slicedSequence = sequence.slice(1);
|
|
slicedSequence.forEach(match => {
|
|
if (match.token.length > longestMatch.token.length) {
|
|
longestMatch = match;
|
|
}
|
|
});
|
|
return longestMatch;
|
|
}
|
|
|
|
getMatchFeedback(match, isSoleMatch) {
|
|
if (this.matchers[match.pattern]) {
|
|
return this.matchers[match.pattern](match, isSoleMatch);
|
|
}
|
|
|
|
if (zxcvbnOptions.matchers[match.pattern] && 'feedback' in zxcvbnOptions.matchers[match.pattern]) {
|
|
return zxcvbnOptions.matchers[match.pattern].feedback(match, isSoleMatch);
|
|
}
|
|
|
|
return defaultFeedback;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* @link https://davidwalsh.name/javascript-debounce-function
|
|
*/
|
|
var debounce = ((func, wait, isImmediate) => {
|
|
let timeout;
|
|
return function debounce(...args) {
|
|
const context = this;
|
|
|
|
const later = () => {
|
|
timeout = undefined;
|
|
|
|
if (!isImmediate) {
|
|
func.apply(context, args);
|
|
}
|
|
};
|
|
|
|
const shouldCallNow = isImmediate && !timeout;
|
|
|
|
if (timeout !== undefined) {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
timeout = setTimeout(later, wait);
|
|
|
|
if (shouldCallNow) {
|
|
return func.apply(context, args);
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
});
|
|
|
|
const time = () => new Date().getTime();
|
|
|
|
const createReturnValue = (resolvedMatches, password, start) => {
|
|
const feedback = new Feedback();
|
|
const timeEstimates = new TimeEstimates();
|
|
const matchSequence = scoring.mostGuessableMatchSequence(password, resolvedMatches);
|
|
const calcTime = time() - start;
|
|
const attackTimes = timeEstimates.estimateAttackTimes(matchSequence.guesses);
|
|
return {
|
|
calcTime,
|
|
...matchSequence,
|
|
...attackTimes,
|
|
feedback: feedback.getFeedback(attackTimes.score, matchSequence.sequence)
|
|
};
|
|
};
|
|
|
|
const main = (password, userInputs) => {
|
|
if (userInputs) {
|
|
zxcvbnOptions.extendUserInputsDictionary(userInputs);
|
|
}
|
|
|
|
const matching = new Matching();
|
|
return matching.match(password);
|
|
};
|
|
|
|
const zxcvbn = (password, userInputs) => {
|
|
const start = time();
|
|
const matches = main(password, userInputs);
|
|
|
|
if (matches instanceof Promise) {
|
|
throw new Error('You are using a Promised matcher, please use `zxcvbnAsync` for it.');
|
|
}
|
|
|
|
return createReturnValue(matches, password, start);
|
|
};
|
|
const zxcvbnAsync = async (password, userInputs) => {
|
|
const start = time();
|
|
const matches = await main(password, userInputs);
|
|
return createReturnValue(matches, password, start);
|
|
};
|
|
|
|
exports.debounce = debounce;
|
|
exports.zxcvbn = zxcvbn;
|
|
exports.zxcvbnAsync = zxcvbnAsync;
|
|
exports.zxcvbnOptions = zxcvbnOptions;
|
|
|
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
|
return exports;
|
|
|
|
})({});
|
|
//# sourceMappingURL=zxcvbn-ts.js.map
|