FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

99
node_modules/@zxcvbn-ts/core/src/Feedback.ts generated vendored Normal file
View File

@@ -0,0 +1,99 @@
import { zxcvbnOptions } from './Options'
import { DefaultFeedbackFunction, FeedbackType, MatchEstimated } from './types'
import bruteforceMatcher from './matcher/bruteforce/feedback'
import dateMatcher from './matcher/date/feedback'
import dictionaryMatcher from './matcher/dictionary/feedback'
import regexMatcher from './matcher/regex/feedback'
import repeatMatcher from './matcher/repeat/feedback'
import sequenceMatcher from './matcher/sequence/feedback'
import spatialMatcher from './matcher/spatial/feedback'
import separatorMatcher from './matcher/separator/feedback'
const defaultFeedback = {
warning: null,
suggestions: [],
}
type Matchers = {
[key: string]: DefaultFeedbackFunction
}
/*
* -------------------------------------------------------------------------------
* Generate feedback ---------------------------------------------------------------
* -------------------------------------------------------------------------------
*/
class Feedback {
readonly matchers: Matchers = {
bruteforce: bruteforceMatcher,
date: dateMatcher,
dictionary: dictionaryMatcher,
regex: regexMatcher,
repeat: repeatMatcher,
sequence: sequenceMatcher,
spatial: spatialMatcher,
separator: separatorMatcher,
}
defaultFeedback: FeedbackType = {
warning: null,
suggestions: [],
}
constructor() {
this.setDefaultSuggestions()
}
setDefaultSuggestions() {
this.defaultFeedback.suggestions.push(
zxcvbnOptions.translations.suggestions.useWords,
zxcvbnOptions.translations.suggestions.noNeed,
)
}
getFeedback(score: number, sequence: MatchEstimated[]) {
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)
} else {
feedback = {
warning: null,
suggestions: [extraFeedback],
}
}
return feedback
}
getLongestMatch(sequence: MatchEstimated[]) {
let longestMatch = sequence[0]
const slicedSequence = sequence.slice(1)
slicedSequence.forEach((match: MatchEstimated) => {
if (match.token.length > longestMatch.token.length) {
longestMatch = match
}
})
return longestMatch
}
getMatchFeedback(match: MatchEstimated, isSoleMatch: boolean) {
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
}
}
export default Feedback

79
node_modules/@zxcvbn-ts/core/src/Matching.ts generated vendored Normal file
View File

@@ -0,0 +1,79 @@
import { extend, sorted } from './helper'
import { MatchExtended, MatchingType } from './types'
import dateMatcher from './matcher/date/matching'
import dictionaryMatcher from './matcher/dictionary/matching'
import regexMatcher from './matcher/regex/matching'
import repeatMatcher from './matcher/repeat/matching'
import sequenceMatcher from './matcher/sequence/matching'
import spatialMatcher from './matcher/spatial/matching'
import separatorMatcher from './matcher/separator/matching'
import { zxcvbnOptions } from './Options'
/*
* -------------------------------------------------------------------------------
* Omnimatch combine matchers ---------------------------------------------------------------
* -------------------------------------------------------------------------------
*/
type Matchers = {
[key: string]: MatchingType
}
class Matching {
readonly matchers: Matchers = {
date: dateMatcher,
dictionary: dictionaryMatcher,
regex: regexMatcher,
// @ts-ignore => TODO resolve this type issue. This is because it is possible to be async
repeat: repeatMatcher,
sequence: sequenceMatcher,
spatial: spatialMatcher,
separator: separatorMatcher,
}
match(password: string): MatchExtended[] | Promise<MatchExtended[]> {
const matches: MatchExtended[] = []
const promises: Promise<MatchExtended[]>[] = []
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, reject) => {
Promise.all(promises)
.then(() => {
resolve(sorted(matches))
})
.catch((error) => {
reject(error)
})
})
}
return sorted(matches)
}
}
export default Matching

176
node_modules/@zxcvbn-ts/core/src/Options.ts generated vendored Normal file
View File

@@ -0,0 +1,176 @@
import { buildRankedDictionary } from './helper'
import {
TranslationKeys,
OptionsType,
OptionsDictionary,
OptionsL33tTable,
OptionsGraph,
RankedDictionaries,
Matchers,
Matcher,
} from './types'
import l33tTable from './data/l33tTable'
import translationKeys from './data/translationKeys'
import TrieNode from './matcher/dictionary/variants/matching/unmunger/TrieNode'
import l33tTableToTrieNode from './matcher/dictionary/variants/matching/unmunger/l33tTableToTrieNode'
export class Options {
matchers: Matchers = {}
l33tTable: OptionsL33tTable = l33tTable
trieNodeRoot: TrieNode = l33tTableToTrieNode(l33tTable, new TrieNode())
dictionary: OptionsDictionary = {
userInputs: [],
}
rankedDictionaries: RankedDictionaries = {}
rankedDictionariesMaxWordSize: Record<string, number> = {}
translations: TranslationKeys = translationKeys
graphs: OptionsGraph = {}
useLevenshteinDistance: boolean = false
levenshteinThreshold: number = 2
l33tMaxSubstitutions: number = 100
maxLength: number = 256
constructor() {
this.setRankedDictionaries()
}
// eslint-disable-next-line max-statements,complexity
setOptions(options: OptionsType = {}) {
if (options.l33tTable) {
this.l33tTable = options.l33tTable
this.trieNodeRoot = l33tTableToTrieNode(options.l33tTable, new TrieNode())
}
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
}
if (options.l33tMaxSubstitutions !== undefined) {
this.l33tMaxSubstitutions = options.l33tMaxSubstitutions
}
if (options.maxLength !== undefined) {
this.maxLength = options.maxLength
}
}
setTranslations(translations: TranslationKeys) {
if (this.checkCustomTranslations(translations)) {
this.translations = translations
} else {
throw new Error('Invalid translations object fallback to keys')
}
}
checkCustomTranslations(translations: TranslationKeys) {
let valid = true
Object.keys(translationKeys).forEach((type) => {
if (type in translations) {
const translationType = type as keyof typeof translationKeys
Object.keys(translationKeys[translationType]).forEach((key) => {
if (!(key in translations[translationType])) {
valid = false
}
})
} else {
valid = false
}
})
return valid
}
setRankedDictionaries() {
const rankedDictionaries: RankedDictionaries = {}
const rankedDictionariesMaxWorkSize: Record<string, number> = {}
Object.keys(this.dictionary).forEach((name) => {
rankedDictionaries[name] = buildRankedDictionary(this.dictionary[name])
rankedDictionariesMaxWorkSize[name] =
this.getRankedDictionariesMaxWordSize(this.dictionary[name])
})
this.rankedDictionaries = rankedDictionaries
this.rankedDictionariesMaxWordSize = rankedDictionariesMaxWorkSize
}
getRankedDictionariesMaxWordSize(list: (string | number)[]) {
const data = list.map((el) => {
if (typeof el !== 'string') {
return el.toString().length
}
return el.length
})
// do not use Math.max(...data) because it can result in max stack size error because every entry will be used as an argument
if (data.length === 0) {
return 0
}
return data.reduce((a, b) => Math.max(a, b), -Infinity)
}
buildSanitizedRankedDictionary(list: (string | number)[]) {
const sanitizedInputs: string[] = []
list.forEach((input: string | number | boolean) => {
const inputType = typeof input
if (
inputType === 'string' ||
inputType === 'number' ||
inputType === 'boolean'
) {
sanitizedInputs.push(input.toString().toLowerCase())
}
})
return buildRankedDictionary(sanitizedInputs)
}
extendUserInputsDictionary(dictionary: (string | number)[]) {
if (!this.dictionary.userInputs) {
this.dictionary.userInputs = []
}
const newList = [...this.dictionary.userInputs, ...dictionary]
this.rankedDictionaries.userInputs =
this.buildSanitizedRankedDictionary(newList)
this.rankedDictionariesMaxWordSize.userInputs =
this.getRankedDictionariesMaxWordSize(newList)
}
public addMatcher(name: string, matcher: Matcher) {
if (this.matchers[name]) {
console.info(`Matcher ${name} already exists`)
} else {
this.matchers[name] = matcher
}
}
}
export const zxcvbnOptions = new Options()

107
node_modules/@zxcvbn-ts/core/src/TimeEstimates.ts generated vendored Normal file
View File

@@ -0,0 +1,107 @@
import { zxcvbnOptions } from './Options'
import { CrackTimesDisplay, CrackTimesSeconds, Score } from './types'
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: string, value: number | undefined) {
let key = displayStr
if (value !== undefined && value !== 1) {
key += 's'
}
const { timeEstimation } = zxcvbnOptions.translations
return timeEstimation[key as keyof typeof timeEstimation].replace(
'{base}',
`${value}`,
)
}
estimateAttackTimes(guesses: number) {
const crackTimesSeconds: CrackTimesSeconds = {
onlineThrottling100PerHour: guesses / (100 / 3600),
onlineNoThrottling10PerSecond: guesses / 10,
offlineSlowHashing1e4PerSecond: guesses / 1e4,
offlineFastHashing1e10PerSecond: guesses / 1e10,
}
const crackTimesDisplay: CrackTimesDisplay = {
onlineThrottling100PerHour: '',
onlineNoThrottling10PerSecond: '',
offlineSlowHashing1e4PerSecond: '',
offlineFastHashing1e10PerSecond: '',
}
Object.keys(crackTimesSeconds).forEach((scenario) => {
const seconds = crackTimesSeconds[scenario as keyof CrackTimesSeconds]
crackTimesDisplay[scenario as keyof CrackTimesDisplay] =
this.displayTime(seconds)
})
return {
crackTimesSeconds,
crackTimesDisplay,
score: this.guessesToScore(guesses),
}
}
guessesToScore(guesses: number): Score {
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: number) {
let displayStr = 'centuries'
let base
const timeKeys = Object.keys(times)
const foundIndex = timeKeys.findIndex(
(time) => seconds < times[time as keyof typeof times],
)
if (foundIndex > -1) {
displayStr = timeKeys[foundIndex - 1]
if (foundIndex !== 0) {
base = Math.round(seconds / times[displayStr as keyof typeof times])
} else {
displayStr = 'ltSecond'
}
}
return this.translate(displayStr, base)
}
}
export default TimeEstimates

38
node_modules/@zxcvbn-ts/core/src/data/const.ts generated vendored Normal file
View File

@@ -0,0 +1,38 @@
import dateSplits from './dateSplits'
export const DATE_MAX_YEAR = 2050
export const DATE_MIN_YEAR = 1000
export const DATE_SPLITS = dateSplits
export const BRUTEFORCE_CARDINALITY = 10
export const MIN_GUESSES_BEFORE_GROWING_SEQUENCE = 10000
export const MIN_SUBMATCH_GUESSES_SINGLE_CHAR = 10
export const MIN_SUBMATCH_GUESSES_MULTI_CHAR = 50
export const MIN_YEAR_SPACE = 20
// \xbf-\xdf is a range for almost all special uppercase letter like Ä and so on
export const START_UPPER = /^[A-Z\xbf-\xdf][^A-Z\xbf-\xdf]+$/
export 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
export const ALL_UPPER = /^[A-Z\xbf-\xdf]+$/
export const ALL_UPPER_INVERTED = /^[^a-z\xdf-\xff]+$/
export const ALL_LOWER = /^[a-z\xdf-\xff]+$/
export const ALL_LOWER_INVERTED = /^[^A-Z\xbf-\xdf]+$/
export const ONE_LOWER = /[a-z\xdf-\xff]/
export const ONE_UPPER = /[A-Z\xbf-\xdf]/
export const ALPHA_INVERTED = /[^A-Za-z\xbf-\xdf]/gi
export const ALL_DIGIT = /^\d+$/
export const REFERENCE_YEAR = new Date().getFullYear()
export const REGEXEN = { recentYear: /19\d\d|200\d|201\d|202\d/g }
/* Separators */
export const SEPERATOR_CHARS = [
' ',
',',
';',
':',
'|',
'/',
'\\',
'_',
'.',
'-',
]
export const SEPERATOR_CHAR_COUNT = SEPERATOR_CHARS.length

29
node_modules/@zxcvbn-ts/core/src/data/dateSplits.ts generated vendored Normal file
View File

@@ -0,0 +1,29 @@
export default {
4: [
// for length-4 strings, eg 1191 or 9111, two ways to split:
[1, 2], // 1 1 91 (2nd split starts at index 1, 3rd at index 2)
[2, 3], // 91 1 1
],
5: [
[1, 3], // 1 11 91
[2, 3], // 11 1 91
// [2, 3], // 91 1 11 <- duplicate previous one
[2, 4], // 91 11 1 <- New and must be added as bug fix
],
6: [
[1, 2], // 1 1 1991
[2, 4], // 11 11 91
[4, 5], // 1991 1 1
],
// 1111991
7: [
[1, 3], // 1 11 1991
[2, 3], // 11 1 1991
[4, 5], // 1991 1 11
[4, 6], // 1991 11 1
],
8: [
[2, 4], // 11 11 1991
[4, 6], // 1991 11 11
],
}

24
node_modules/@zxcvbn-ts/core/src/data/l33tTable.ts generated vendored Normal file
View File

@@ -0,0 +1,24 @@
export default {
a: ['4', '@'],
b: ['8'],
c: ['(', '{', '[', '<'],
d: ['6', '|)'],
e: ['3'],
f: ['#'],
g: ['6', '9', '&'],
h: ['#', '|-|'],
i: ['1', '!', '|'],
k: ['<', '|<'],
l: ['!', '1', '|', '7'],
m: ['^^', 'nn', '2n', '/\\\\/\\\\'],
n: ['//'],
o: ['0', '()'],
q: ['9'],
u: ['|_|'],
s: ['$', '5'],
t: ['+', '7'],
v: ['<', '>', '/'],
w: ['^/', 'uu', 'vv', '2u', '2v', '\\\\/\\\\/'],
x: ['%', '><'],
z: ['2'],
}

View File

@@ -0,0 +1,52 @@
export default {
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',
},
}

33
node_modules/@zxcvbn-ts/core/src/debounce.ts generated vendored Normal file
View File

@@ -0,0 +1,33 @@
export type Procedure = (...args: any[]) => void
/**
* @link https://davidwalsh.name/javascript-debounce-function
* @param func needs to implement a function which is debounced
* @param wait how long do you want to wait till the previous declared function is executed
* @param isImmediate defines if you want to execute the function on the first execution or the last execution inside the time window. `true` for first and `false` for last.
*/
export default <F extends Procedure>(
func: F,
wait: number,
isImmediate?: boolean,
): ((this: ThisParameterType<F>, ...args: Parameters<F>) => void) => {
let timeout: ReturnType<typeof setTimeout> | undefined
return function debounce(this: ThisParameterType<F>, ...args: Parameters<F>) {
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
}
}

35
node_modules/@zxcvbn-ts/core/src/helper.ts generated vendored Normal file
View File

@@ -0,0 +1,35 @@
import { LooseObject, MatchExtended } from './types'
export const empty = (obj: LooseObject) => Object.keys(obj).length === 0
export const extend = (listToExtend: any[], list: any[]) =>
// eslint-disable-next-line prefer-spread
listToExtend.push.apply(listToExtend, list)
export const translate = (string: string, chrMap: LooseObject) => {
let newString = string
Object.entries(chrMap).forEach(([key, value]) => {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(escapedKey, 'g')
newString = newString.replace(regex, value)
})
return newString
}
// mod implementation that works for negative numbers
export const mod = (n: number, m: number) => ((n % m) + m) % m
// sort on i primary, j secondary
export const sorted = (matches: MatchExtended[]) =>
matches.sort((m1, m2) => m1.i - m2.i || m1.j - m2.j)
export const buildRankedDictionary = (orderedList: any[]) => {
const result: LooseObject = {}
let counter = 1 // rank starts at 1, not 0
orderedList.forEach((word) => {
result[word] = counter
counter += 1
})
return result
}

67
node_modules/@zxcvbn-ts/core/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,67 @@
import Matching from './Matching'
import scoring from './scoring'
import TimeEstimates from './TimeEstimates'
import Feedback from './Feedback'
import { zxcvbnOptions, Options } from './Options'
import debounce from './debounce'
import { MatchExtended, ZxcvbnResult } from './types'
const time = () => new Date().getTime()
const createReturnValue = (
resolvedMatches: MatchExtended[],
password: string,
start: number,
): ZxcvbnResult => {
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: string, userInputs?: (string | number)[]) => {
if (userInputs) {
zxcvbnOptions.extendUserInputsDictionary(userInputs)
}
const matching = new Matching()
return matching.match(password)
}
export const zxcvbn = (password: string, userInputs?: (string | number)[]) => {
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)
}
export const zxcvbnAsync = async (
password: string,
userInputs?: (string | number)[],
): Promise<ZxcvbnResult> => {
const usedPassword = password.substring(0, zxcvbnOptions.maxLength)
const start = time()
const matches = await main(usedPassword, userInputs)
return createReturnValue(matches, usedPassword, start)
}
export * from './types'
export { zxcvbnOptions, Options, debounce }

51
node_modules/@zxcvbn-ts/core/src/levenshtein.ts generated vendored Normal file
View File

@@ -0,0 +1,51 @@
import { distance } from 'fastest-levenshtein'
import { LooseObject } from './types'
const getUsedThreshold = (
password: string,
entry: string,
threshold: number,
) => {
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
}
export interface FindLevenshteinDistanceResult {
levenshteinDistance: number
levenshteinDistanceEntry: string
}
const findLevenshteinDistance = (
password: string,
rankedDictionary: LooseObject,
threshold: number,
): Partial<FindLevenshteinDistanceResult> => {
let foundDistance = 0
const found = Object.keys(rankedDictionary).find((entry) => {
const usedThreshold = getUsedThreshold(password, entry, threshold)
if (Math.abs(password.length - entry.length) > usedThreshold) {
return false
}
const foundEntryDistance = distance(password, entry)
const isInThreshold = foundEntryDistance <= usedThreshold
if (isInThreshold) {
foundDistance = foundEntryDistance
}
return isInThreshold
})
if (found) {
return {
levenshteinDistance: foundDistance,
levenshteinDistanceEntry: found,
}
}
return {}
}
export default findLevenshteinDistance

View File

@@ -0,0 +1,3 @@
export default () => {
return null
}

View File

@@ -0,0 +1,23 @@
import {
BRUTEFORCE_CARDINALITY,
MIN_SUBMATCH_GUESSES_SINGLE_CHAR,
MIN_SUBMATCH_GUESSES_MULTI_CHAR,
} from '../../data/const'
import { MatchEstimated, MatchExtended } from '../../types'
export default ({ token }: MatchExtended | MatchEstimated) => {
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)
}

View File

@@ -0,0 +1,8 @@
import { zxcvbnOptions } from '../../Options'
export default () => {
return {
warning: zxcvbnOptions.translations.warnings.dates,
suggestions: [zxcvbnOptions.translations.suggestions.dates],
}
}

View File

@@ -0,0 +1,285 @@
import {
DATE_MAX_YEAR,
DATE_MIN_YEAR,
DATE_SPLITS,
REFERENCE_YEAR,
} from '../../data/const'
import { sorted } from '../../helper'
import { DateMatch } from '../../types'
interface DateMatchOptions {
password: string
}
/*
* -------------------------------------------------------------------------------
* 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 }: DateMatchOptions) {
const matches: DateMatch[] = [
...this.getMatchesWithoutSeparator(password),
...this.getMatchesWithSeparator(password),
]
const filteredMatches = this.filterNoise(matches)
return sorted(filteredMatches)
}
getMatchesWithSeparator(password: string) {
const matches: DateMatch[] = []
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: string) {
const matches: DateMatch[] = []
const maybeDateNoSeparator = /^\d{4,8}$/
const metric = (candidate: DateMatch) =>
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: any[] = []
const index = token.length
const splittedDates = DATE_SPLITS[index as keyof typeof DATE_SPLITS]
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: DateMatch[]) {
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: number[]) {
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: number[]) {
// first look for a four digit year: yyyy + daymonth or daymonth + yyyy
const possibleYearSplits: [number, number[]][] = [
[integers[2], integers.slice(0, 2)], // year last
[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: number[]) {
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: number) {
if (year > 99) {
return year
}
if (year > 50) {
// 87 -> 1987
return year + 1900
}
// 15 -> 2015
return year + 2000
}
}
export default MatchDate

View File

@@ -0,0 +1,14 @@
import { MIN_YEAR_SPACE, REFERENCE_YEAR } from '../../data/const'
import { MatchEstimated, MatchExtended } from '../../types'
export default ({ year, separator }: MatchExtended | MatchEstimated) => {
// 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
}

View File

@@ -0,0 +1,82 @@
import { zxcvbnOptions } from '../../Options'
import { MatchEstimated } from '../../types'
import { ALL_UPPER_INVERTED, START_UPPER } from '../../data/const'
const getDictionaryWarningPassword = (
match: MatchEstimated,
isSoleMatch?: boolean,
) => {
let warning: string | null = null
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: MatchEstimated,
isSoleMatch?: boolean,
) => {
let warning: string | null = null
if (isSoleMatch) {
warning = zxcvbnOptions.translations.warnings.wordByItself
}
return warning
}
const getDictionaryWarningNames = (
match: MatchEstimated,
isSoleMatch?: boolean,
) => {
if (isSoleMatch) {
return zxcvbnOptions.translations.warnings.namesByThemselves
}
return zxcvbnOptions.translations.warnings.commonNames
}
const getDictionaryWarning = (match: MatchEstimated, isSoleMatch?: boolean) => {
let warning: string | null = null
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
}
export default (match: MatchEstimated, isSoleMatch?: boolean) => {
const warning = getDictionaryWarning(match, isSoleMatch)
const suggestions: string[] = []
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,
}
}

View File

@@ -0,0 +1,95 @@
import findLevenshteinDistance, {
FindLevenshteinDistanceResult,
} from '../../levenshtein'
import { sorted } from '../../helper'
import { zxcvbnOptions } from '../../Options'
import { DictionaryNames, DictionaryMatch, L33tMatch } from '../../types'
import Reverse from './variants/matching/reverse'
import L33t from './variants/matching/l33t'
import { DictionaryMatchOptions } from './types'
class MatchDictionary {
l33t: L33t
reverse: Reverse
constructor() {
this.l33t = new L33t(this.defaultMatch)
this.reverse = new Reverse(this.defaultMatch)
}
match({ password }: DictionaryMatchOptions) {
const matches = [
...(this.defaultMatch({
password,
}) as DictionaryMatch[]),
...(this.reverse.match({ password }) as DictionaryMatch[]),
...(this.l33t.match({ password }) as L33tMatch[]),
]
return sorted(matches)
}
defaultMatch({ password, useLevenshtein = true }: DictionaryMatchOptions) {
const matches: DictionaryMatch[] = []
const passwordLength = password.length
const passwordLower = password.toLowerCase()
// eslint-disable-next-line complexity,max-statements
Object.keys(zxcvbnOptions.rankedDictionaries).forEach((dictionaryName) => {
const rankedDict =
zxcvbnOptions.rankedDictionaries[dictionaryName as DictionaryNames]
const longestDictionaryWordSize =
zxcvbnOptions.rankedDictionariesMaxWordSize[dictionaryName]
const searchWidth = Math.min(longestDictionaryWordSize, passwordLength)
for (let i = 0; i < passwordLength; i += 1) {
const searchEnd = Math.min(i + searchWidth, passwordLength)
for (let j = i; j < searchEnd; j += 1) {
const usedPassword = passwordLower.slice(i, +j + 1 || 9e9)
const isInDictionary = usedPassword in rankedDict
let foundLevenshteinDistance: Partial<FindLevenshteinDistanceResult> =
{}
// 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 &&
useLevenshtein
) {
foundLevenshteinDistance = findLevenshteinDistance(
usedPassword,
rankedDict,
zxcvbnOptions.levenshteinThreshold,
)
}
const isLevenshteinMatch =
Object.keys(foundLevenshteinDistance).length !== 0
if (isInDictionary || isLevenshteinMatch) {
const usedRankPassword = isLevenshteinMatch
? (foundLevenshteinDistance.levenshteinDistanceEntry as string)
: usedPassword
const rank = rankedDict[usedRankPassword]
matches.push({
pattern: 'dictionary',
i,
j,
token: password.slice(i, +j + 1 || 9e9),
matchedWord: usedPassword,
rank,
dictionaryName: dictionaryName as DictionaryNames,
reversed: false,
l33t: false,
...foundLevenshteinDistance,
})
}
}
}
})
return matches
}
}
export default MatchDictionary

View File

@@ -0,0 +1,39 @@
import uppercaseVariant from './variants/scoring/uppercase'
import l33tVariant from './variants/scoring/l33t'
import { MatchEstimated, MatchExtended } from '../../types'
export interface DictionaryReturn {
baseGuesses: number
uppercaseVariations: number
l33tVariations: number
calculation: number
}
export default ({
rank,
reversed,
l33t,
subs,
token,
dictionaryName,
}: MatchExtended | MatchEstimated): DictionaryReturn => {
const baseGuesses = rank // keep these as properties for display purposes
const uppercaseVariations = uppercaseVariant(token)
const l33tVariations = l33tVariant({ l33t, subs, token })
const reversedVariations = (reversed && 2) || 1
let calculation
if (dictionaryName === 'diceware') {
// diceware dictionaries are special, so we get a simple scoring of 1/2 of 6^5 (6 digits on 5 dice)
// to get fix entropy of ~12.9 bits for every entry https://en.wikipedia.org/wiki/Diceware#:~:text=The%20level%20of,bits
calculation = 6 ** 5 / 2
} else {
calculation =
baseGuesses * uppercaseVariations * l33tVariations * reversedVariations
}
return {
baseGuesses,
uppercaseVariations,
l33tVariations,
calculation,
}
}

View File

@@ -0,0 +1,10 @@
import { DictionaryMatch } from '../../types'
export interface DictionaryMatchOptions {
password: string
useLevenshtein?: boolean
}
export type DefaultMatch = (
options: DictionaryMatchOptions,
) => DictionaryMatch[]

View File

@@ -0,0 +1,118 @@
import { zxcvbnOptions } from '../../../../Options'
import { DictionaryMatch, L33tMatch } from '../../../../types'
import { DefaultMatch } from '../../types'
import getCleanPasswords, {
PasswordChanges,
PasswordWithSubs,
} from './unmunger/getCleanPasswords'
const getExtras = (
passwordWithSubs: PasswordWithSubs,
i: number,
j: number,
) => {
const previousChanges = passwordWithSubs.changes.filter((changes) => {
return changes.i < i
})
const iUnsubbed = previousChanges.reduce((value, change) => {
return value - change.letter.length + change.substitution.length
}, i)
const usedChanges = passwordWithSubs.changes.filter((changes) => {
return changes.i >= i && changes.i <= j
})
const jUnsubbed = usedChanges.reduce(
(value, change) => {
return value - change.letter.length + change.substitution.length
},
j - i + iUnsubbed,
)
const filtered: PasswordChanges[] = []
const subDisplay: string[] = []
usedChanges.forEach((value) => {
const existingIndex = filtered.findIndex((t) => {
return t.letter === value.letter && t.substitution === value.substitution
})
if (existingIndex < 0) {
filtered.push({
letter: value.letter,
substitution: value.substitution,
})
subDisplay.push(`${value.substitution} -> ${value.letter}`)
}
})
return {
i: iUnsubbed,
j: jUnsubbed,
subs: filtered,
subDisplay: subDisplay.join(', '),
}
}
/*
* -------------------------------------------------------------------------------
* Dictionary l33t matching -----------------------------------------------------
* -------------------------------------------------------------------------------
*/
class MatchL33t {
defaultMatch: DefaultMatch
constructor(defaultMatch: DefaultMatch) {
this.defaultMatch = defaultMatch
}
isAlreadyIncluded(matches: L33tMatch[], newMatch: L33tMatch) {
return matches.some((l33tMatch) => {
return Object.entries(l33tMatch).every(([key, value]) => {
return key === 'subs' || value === newMatch[key]
})
})
}
match({ password }: { password: string }) {
const matches: L33tMatch[] = []
const subbedPasswords = getCleanPasswords(
password,
zxcvbnOptions.l33tMaxSubstitutions,
zxcvbnOptions.trieNodeRoot,
)
let hasFullMatch = false
let isFullSubstitution = true
subbedPasswords.forEach((subbedPassword) => {
if (hasFullMatch) {
return
}
const matchedDictionary = this.defaultMatch({
password: subbedPassword.password,
useLevenshtein: isFullSubstitution,
})
// only the first entry has a full substitution
isFullSubstitution = false
matchedDictionary.forEach((match: DictionaryMatch) => {
if (!hasFullMatch) {
hasFullMatch = match.i === 0 && match.j === password.length - 1
}
const extras = getExtras(subbedPassword, match.i, match.j)
const token = password.slice(extras.i, +extras.j + 1 || 9e9)
const newMatch: L33tMatch = {
...match,
l33t: true,
token,
...extras,
}
const alreadyIncluded = this.isAlreadyIncluded(matches, newMatch)
// only return the matches that contain an actual substitution
if (token.toLowerCase() !== match.matchedWord && !alreadyIncluded) {
matches.push(newMatch)
}
})
})
// 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)
}
}
export default MatchL33t

View File

@@ -0,0 +1,31 @@
import { DictionaryMatch } from '../../../../types'
import { DefaultMatch } from '../../types'
/*
* -------------------------------------------------------------------------------
* Dictionary reverse matching --------------------------------------------------
* -------------------------------------------------------------------------------
*/
class MatchReverse {
defaultMatch: DefaultMatch
constructor(defaultMatch: DefaultMatch) {
this.defaultMatch = defaultMatch
}
match({ password }: { password: string }) {
const passwordReversed = password.split('').reverse().join('')
return this.defaultMatch({
password: passwordReversed,
}).map((match: DictionaryMatch) => ({
...match,
token: match.token.split('').reverse().join(''), // reverse back
reversed: true,
// map coordinates back to original string
i: password.length - 1 - match.j,
j: password.length - 1 - match.i,
}))
}
}
export default MatchReverse

View File

@@ -0,0 +1,43 @@
export default class TrieNode {
constructor(public parents: string[] = []) {}
// eslint-disable-next-line no-use-before-define
children: Map<string, TrieNode> = new Map()
subs?: string[]
addSub(key: string, ...subs: string[]): TrieNode {
const firstChar = key.charAt(0)
if (!this.children.has(firstChar)) {
this.children.set(firstChar, new TrieNode([...this.parents, firstChar]))
}
let cur = this.children.get(firstChar)!
for (let i = 1; i < key.length; i += 1) {
const c = key.charAt(i)
if (!cur.hasChild(c)) {
cur.addChild(c)
}
cur = cur.getChild(c)!
}
cur.subs = (cur.subs || []).concat(subs)
return this
}
getChild(child: string): TrieNode | undefined {
return this.children.get(child)
}
isTerminal(): boolean {
return !!this.subs
}
addChild(child: string): void {
if (!this.hasChild(child)) {
this.children.set(child, new TrieNode([...this.parents, child]))
}
}
hasChild(child: string): boolean {
return this.children.has(child)
}
}

View File

@@ -0,0 +1,190 @@
import TrieNode from './TrieNode'
interface GetAllSubCombosHelperOptions {
substr: string
limit: number
trieRoot: TrieNode
}
export interface PasswordChanges {
letter: string
substitution: string
}
export type IndexedPasswordChanges = PasswordChanges & { i: number }
export interface PasswordWithSubs {
password: string
changes: IndexedPasswordChanges[]
}
interface HelperOptions {
onlyFullSub: boolean
isFullSub: boolean
index: number
subIndex: number
changes: IndexedPasswordChanges[]
lastSubLetter?: string
consecutiveSubCount: number
}
class CleanPasswords {
private substr: string
private buffer: string[] = []
private limit: number
private trieRoot: TrieNode
private finalPasswords: PasswordWithSubs[] = []
constructor({ substr, limit, trieRoot }: GetAllSubCombosHelperOptions) {
this.substr = substr
this.limit = limit
this.trieRoot = trieRoot
}
private getAllPossibleSubsAtIndex(index: number) {
const nodes: TrieNode[] = []
let cur = this.trieRoot
for (let i = index; i < this.substr.length; i += 1) {
const character = this.substr.charAt(i)
cur = cur.getChild(character)!
if (!cur) {
break
}
nodes.push(cur)
}
return nodes
}
// eslint-disable-next-line complexity,max-statements
private helper({
onlyFullSub,
isFullSub,
index,
subIndex,
changes,
lastSubLetter,
consecutiveSubCount,
}: HelperOptions): void {
if (this.finalPasswords.length >= this.limit) {
return
}
if (index === this.substr.length) {
if (onlyFullSub === isFullSub) {
this.finalPasswords.push({ password: this.buffer.join(''), changes })
}
return
}
// first, exhaust all possible substitutions at this index
const nodes: TrieNode[] = [...this.getAllPossibleSubsAtIndex(index)]
let hasSubs = false
// iterate backward to get wider substitutions first
for (let i = index + nodes.length - 1; i >= index; i -= 1) {
const cur = nodes[i - index]
if (cur.isTerminal()) {
// Skip if this would be a 4th or more consecutive substitution of the same letter
// this should work in all language as there shouldn't be the same letter more than four times in a row
// So we can ignore the rest to save calculation time
if (
lastSubLetter === cur.parents.join('') &&
consecutiveSubCount >= 3
) {
// eslint-disable-next-line no-continue
continue
}
hasSubs = true
const subs = cur.subs!
// eslint-disable-next-line no-restricted-syntax
for (const sub of subs) {
this.buffer.push(sub)
const newSubs = changes.concat({
i: subIndex,
letter: sub,
substitution: cur.parents.join(''),
})
// recursively build the rest of the string
this.helper({
onlyFullSub,
isFullSub,
index: i + 1,
subIndex: subIndex + sub.length,
changes: newSubs,
lastSubLetter: cur.parents.join(''),
consecutiveSubCount:
lastSubLetter === cur.parents.join('')
? consecutiveSubCount + 1
: 1,
})
// backtrack by ignoring the added postfix
this.buffer.pop()
if (this.finalPasswords.length >= this.limit) {
return
}
}
}
}
// next, generate all combos without doing a substitution at this index
// if a partial substitution is requested or there are no substitutions at this index
if (!onlyFullSub || !hasSubs) {
const firstChar = this.substr.charAt(index)
this.buffer.push(firstChar)
this.helper({
onlyFullSub,
isFullSub: isFullSub && !hasSubs,
index: index + 1,
subIndex: subIndex + 1,
changes,
lastSubLetter,
consecutiveSubCount,
})
this.buffer.pop()
}
}
getAll() {
// only full substitution
this.helper({
onlyFullSub: true,
isFullSub: true,
index: 0,
subIndex: 0,
changes: [],
lastSubLetter: undefined,
consecutiveSubCount: 0,
})
// only partial substitution
this.helper({
onlyFullSub: false,
isFullSub: true,
index: 0,
subIndex: 0,
changes: [],
lastSubLetter: undefined,
consecutiveSubCount: 0,
})
return this.finalPasswords
}
}
const getCleanPasswords = (
password: string,
limit: number,
trieRoot: TrieNode,
): PasswordWithSubs[] => {
const helper = new CleanPasswords({
substr: password,
limit,
trieRoot,
})
return helper.getAll()
}
export default getCleanPasswords

View File

@@ -0,0 +1,11 @@
import { OptionsL33tTable } from '../../../../../types'
import TrieNode from './TrieNode'
export default (l33tTable: OptionsL33tTable, triNode: TrieNode) => {
Object.entries(l33tTable).forEach(([letter, substitutions]) => {
substitutions.forEach((substitution) => {
triNode.addSub(substitution, letter)
})
})
return triNode
}

View File

@@ -0,0 +1,64 @@
import utils from '../../../../scoring/utils'
import { PasswordChanges } from '../matching/unmunger/getCleanPasswords'
export interface L33tOptions {
l33t: string
subs: PasswordChanges[]
token: string
}
export interface GetCountsOptions {
token: string
sub: PasswordChanges
}
const countSubstring = (string: string, substring: string) => {
let count = 0
let pos = string.indexOf(substring)
while (pos >= 0) {
count += 1
pos = string.indexOf(substring, pos + substring.length)
}
return count
}
const getCounts = ({ sub, token }: GetCountsOptions) => {
// lower-case match.token before calculating: capitalization shouldn't affect l33t calc.
const tokenLower = token.toLowerCase()
// num of subbed chars
const subbedCount = countSubstring(tokenLower, sub.substitution)
// num of unsubbed chars
const unsubbedCount = countSubstring(tokenLower, sub.letter)
return {
subbedCount,
unsubbedCount,
}
}
export default ({ l33t, subs, token }: L33tOptions) => {
if (!l33t) {
return 1
}
let variations = 1
subs.forEach((sub) => {
const { subbedCount, unsubbedCount } = getCounts({ sub, 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
}

View File

@@ -0,0 +1,54 @@
import utils from '../../../../scoring/utils'
import {
START_UPPER,
END_UPPER,
ALL_UPPER_INVERTED,
ALL_LOWER_INVERTED,
ONE_LOWER,
ONE_UPPER,
ALPHA_INVERTED,
} from '../../../../data/const'
const getVariations = (cleanedWord: string) => {
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
}
export default (word: string) => {
// 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)
}

View File

@@ -0,0 +1,18 @@
import { zxcvbnOptions } from '../../Options'
import { MatchEstimated } from '../../types'
export default (match: MatchEstimated) => {
if (match.regexName === 'recentYear') {
return {
warning: zxcvbnOptions.translations.warnings.recentYears,
suggestions: [
zxcvbnOptions.translations.suggestions.recentYears,
zxcvbnOptions.translations.suggestions.associatedYears,
],
}
}
return {
warning: null,
suggestions: [],
}
}

View File

@@ -0,0 +1,43 @@
import { REGEXEN } from '../../data/const'
import { sorted } from '../../helper'
import { RegexMatch } from '../../types'
interface RegexMatchOptions {
password: string
regexes?: typeof REGEXEN
}
type RegexesKeys = keyof typeof REGEXEN
/*
* -------------------------------------------------------------------------------
* regex matching ---------------------------------------------------------------
* -------------------------------------------------------------------------------
*/
class MatchRegex {
match({ password, regexes = REGEXEN }: RegexMatchOptions) {
const matches: RegexMatch[] = []
Object.keys(regexes).forEach((name) => {
const regex = regexes[name as RegexesKeys]
regex.lastIndex = 0 // keeps regexMatch stateless
let regexMatch: RegExpExecArray | null
// eslint-disable-next-line no-cond-assign
while ((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 as RegexesKeys,
regexMatch,
})
}
}
})
return sorted(matches)
}
}
export default MatchRegex

View File

@@ -0,0 +1,34 @@
import { MIN_YEAR_SPACE, REFERENCE_YEAR } from '../../data/const'
import { MatchEstimated, MatchExtended } from '../../types'
export default ({
regexName,
regexMatch,
token,
}: MatchExtended | MatchEstimated) => {
const charClassBases = {
alphaLower: 26,
alphaUpper: 26,
alpha: 52,
alphanumeric: 62,
digits: 10,
symbols: 33,
}
if (regexName in charClassBases) {
return (
charClassBases[regexName as keyof typeof charClassBases] ** 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
}

View File

@@ -0,0 +1,14 @@
import { zxcvbnOptions } from '../../Options'
import { MatchEstimated } from '../../types'
export default (match: MatchEstimated) => {
let warning = zxcvbnOptions.translations.warnings.extendedRepeat
if (match.baseToken.length === 1) {
warning = zxcvbnOptions.translations.warnings.simpleRepeat
}
return {
warning,
suggestions: [zxcvbnOptions.translations.suggestions.repeated],
}
}

View File

@@ -0,0 +1,138 @@
import { RepeatMatch } from '../../types'
import scoring from '../../scoring'
import Matching from '../../Matching'
interface RepeatMatchOptions {
password: string
omniMatch: Matching
}
/*
*-------------------------------------------------------------------------------
* repeats (aaa, abcabcabc) ------------------------------
*-------------------------------------------------------------------------------
*/
class MatchRepeat {
// eslint-disable-next-line max-statements
match({ password, omniMatch }: RepeatMatchOptions) {
const matches: (RepeatMatch | Promise<RepeatMatch>)[] = []
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 as RepeatMatch[]
}
// eslint-disable-next-line max-params
normalizeMatch(
baseToken: string,
j: number,
match: RegExpExecArray,
baseGuesses: number | Promise<number>,
) {
const baseMatch: RepeatMatch = {
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,
} as RepeatMatch
})
}
return {
...baseMatch,
baseGuesses,
} as RepeatMatch
}
getGreedyMatch(password: string, lastIndex: number) {
const greedy = /(.+)\1+/g
greedy.lastIndex = lastIndex
return greedy.exec(password)
}
getLazyMatch(password: string, lastIndex: number) {
const lazy = /(.+?)\1+/g
lazy.lastIndex = lastIndex
return lazy.exec(password)
}
setMatchToken(
greedyMatch: RegExpExecArray,
lazyMatch: RegExpExecArray | null,
) {
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: string, omniMatch: Matching) {
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
}
}
export default MatchRepeat

View File

@@ -0,0 +1,4 @@
import { MatchEstimated, MatchExtended } from '../../types'
export default ({ baseGuesses, repeatCount }: MatchExtended | MatchEstimated) =>
baseGuesses * repeatCount

View File

@@ -0,0 +1,4 @@
export default () => {
// no suggestions
return null
}

View File

@@ -0,0 +1,77 @@
import { SEPERATOR_CHARS } from '../../data/const'
import { SeparatorMatch } from '../../types'
interface SeparatorMatchOptions {
password: string
}
const separatorRegex = new RegExp(`[${SEPERATOR_CHARS.join('')}]`)
/*
*-------------------------------------------------------------------------------
* separators (any semi-repeated special character) -----------------------------
*-------------------------------------------------------------------------------
*/
class MatchSeparator {
static getMostUsedSeparatorChar(password: string): string | undefined {
const mostUsedSeperators = [
...password
.split('')
.filter((c) => separatorRegex.test(c))
.reduce((memo, c) => {
const m = memo.get(c)
if (m) {
memo.set(c, m + 1)
} else {
memo.set(c, 1)
}
return memo
}, new Map())
.entries(),
].sort(([_a, a], [_b, b]) => b - a)
if (!mostUsedSeperators.length) return undefined
const match = mostUsedSeperators[0]
// If the special character is only used once, don't treat it like a separator
if (match[1] < 2) return undefined
return match[0]
}
static getSeparatorRegex(separator: string): RegExp {
return new RegExp(`([^${separator}\n])(${separator})(?!${separator})`, 'g')
// negative lookbehind can be added again in a few years when it is more supported by the browsers (currently 2023)
// https://github.com/zxcvbn-ts/zxcvbn/issues/202
// return new RegExp(`(?<!${separator})(${separator})(?!${separator})`, 'g')
}
// eslint-disable-next-line max-statements
match({ password }: SeparatorMatchOptions) {
const result: SeparatorMatch[] = []
if (password.length === 0) return result
const mostUsedSpecial = MatchSeparator.getMostUsedSeparatorChar(password)
if (mostUsedSpecial === undefined) return result
const isSeparator = MatchSeparator.getSeparatorRegex(mostUsedSpecial)
// eslint-disable-next-line no-restricted-syntax
for (const match of password.matchAll(isSeparator)) {
// eslint-disable-next-line no-continue
if (match.index === undefined) continue
// add one to the index because we changed the regex from negative lookbehind to something simple.
// this simple approach uses the first character before the separater too but we only need the index of the separater
// https://github.com/zxcvbn-ts/zxcvbn/issues/202
const i = match.index + 1
result.push({
pattern: 'separator',
token: mostUsedSpecial,
i,
j: i,
})
}
return result
}
}
export default MatchSeparator

View File

@@ -0,0 +1,5 @@
import { SEPERATOR_CHAR_COUNT } from '../../data/const'
export default () => {
return SEPERATOR_CHAR_COUNT
}

View File

@@ -0,0 +1,8 @@
import { zxcvbnOptions } from '../../Options'
export default () => {
return {
warning: zxcvbnOptions.translations.warnings.sequences,
suggestions: [zxcvbnOptions.translations.suggestions.sequences],
}
}

View File

@@ -0,0 +1,117 @@
import { ALL_UPPER, ALL_LOWER, ALL_DIGIT } from '../../data/const'
import { SequenceMatch } from '../../types'
type UpdateParams = {
i: number
j: number
delta: number
password: string
result: any[]
}
interface SequenceMatchOptions {
password: string
}
/*
*-------------------------------------------------------------------------------
* sequences (abcdef) ------------------------------
*-------------------------------------------------------------------------------
*/
class MatchSequence {
MAX_DELTA = 5
// eslint-disable-next-line max-statements
match({ password }: SequenceMatchOptions) {
/*
* 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: SequenceMatch[] = []
if (password.length === 1) {
return []
}
let i = 0
let lastDelta: number | null = 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 as number,
password,
result,
})
return result
}
update({ i, j, delta, password, result }: UpdateParams) {
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: string) {
// 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,
}
}
}
export default MatchSequence

View File

@@ -0,0 +1,23 @@
import { MatchEstimated, MatchExtended } from '../../types'
export default ({ token, ascending }: MatchExtended | MatchEstimated) => {
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
}

View File

@@ -0,0 +1,13 @@
import { zxcvbnOptions } from '../../Options'
import { MatchEstimated } from '../../types'
export default (match: MatchEstimated) => {
let warning = zxcvbnOptions.translations.warnings.keyPattern
if (match.turns === 1) {
warning = zxcvbnOptions.translations.warnings.straightRow
}
return {
warning,
suggestions: [zxcvbnOptions.translations.suggestions.longerKeyboardPattern],
}
}

View File

@@ -0,0 +1,116 @@
import { sorted, extend } from '../../helper'
import { zxcvbnOptions } from '../../Options'
import { LooseObject, SpatialMatch } from '../../types'
interface SpatialMatchOptions {
password: string
}
/*
* ------------------------------------------------------------------------------
* spatial match (qwerty/dvorak/keypad and so on) -----------------------------------------
* ------------------------------------------------------------------------------
*/
class MatchSpatial {
SHIFTED_RX = /[~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?]/
match({ password }: SpatialMatchOptions) {
const matches: SpatialMatch[] = []
Object.keys(zxcvbnOptions.graphs).forEach((graphName) => {
const graph = zxcvbnOptions.graphs[graphName]
extend(matches, this.helper(password, graph, graphName))
})
return sorted(matches)
}
checkIfShifted(graphName: string, password: string, index: number) {
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: string, graph: LooseObject, graphName: string) {
let shiftedCount
const matches: SpatialMatch[] = []
let i = 0
const passwordLength = password.length
while (i < passwordLength - 1) {
let j = i + 1
let lastDirection: number | null = null
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 as keyof typeof graph] || []
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
}
}
export default MatchSpatial

View File

@@ -0,0 +1,64 @@
import utils from '../../scoring/utils'
import { zxcvbnOptions } from '../../Options'
import { LooseObject, MatchEstimated, MatchExtended } from '../../types'
interface EstimatePossiblePatternsOptions {
token: string
graph: string
turns: number
}
const calcAverageDegree = (graph: LooseObject) => {
let average = 0
Object.keys(graph).forEach((key) => {
const neighbors = graph[key]
average += neighbors.filter((entry: string) => !!entry).length
})
average /= Object.entries(graph).length
return average
}
const estimatePossiblePatterns = ({
token,
graph,
turns,
}: EstimatePossiblePatternsOptions) => {
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
}
export default ({
graph,
token,
shiftedCount,
turns,
}: MatchExtended | MatchEstimated) => {
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)
}

96
node_modules/@zxcvbn-ts/core/src/scoring/estimate.ts generated vendored Normal file
View File

@@ -0,0 +1,96 @@
import {
MIN_SUBMATCH_GUESSES_SINGLE_CHAR,
MIN_SUBMATCH_GUESSES_MULTI_CHAR,
} from '../data/const'
import utils from './utils'
import { zxcvbnOptions } from '../Options'
import {
DefaultScoringFunction,
LooseObject,
MatchEstimated,
MatchExtended,
} from '../types'
import bruteforceMatcher from '../matcher/bruteforce/scoring'
import dateMatcher from '../matcher/date/scoring'
import dictionaryMatcher from '../matcher/dictionary/scoring'
import regexMatcher from '../matcher/regex/scoring'
import repeatMatcher from '../matcher/repeat/scoring'
import sequenceMatcher from '../matcher/sequence/scoring'
import spatialMatcher from '../matcher/spatial/scoring'
import separatorMatcher from '../matcher/separator/scoring'
const getMinGuesses = (
match: MatchExtended | MatchEstimated,
password: string,
) => {
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
}
type Matchers = {
[key: string]: DefaultScoringFunction
}
const matchers: Matchers = {
bruteforce: bruteforceMatcher,
date: dateMatcher,
dictionary: dictionaryMatcher,
regex: regexMatcher,
repeat: repeatMatcher,
sequence: sequenceMatcher,
spatial: spatialMatcher,
separator: separatorMatcher,
}
const getScoring = (name: string, match: MatchExtended | MatchEstimated) => {
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 ---------------------------
// ------------------------------------------------------------------------------
// eslint-disable-next-line complexity, max-statements
export default (match: MatchExtended | MatchEstimated, password: string) => {
const extraData: LooseObject = {}
// 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),
}
}

240
node_modules/@zxcvbn-ts/core/src/scoring/index.ts generated vendored Normal file
View File

@@ -0,0 +1,240 @@
import utils from './utils'
import estimateGuesses from './estimate'
import { MIN_GUESSES_BEFORE_GROWING_SEQUENCE } from '../data/const'
import {
MatchExtended,
BruteForceMatch,
MatchEstimated,
LooseObject,
} from '../types'
const scoringHelper = {
password: '',
optimal: {} as any,
excludeAdditive: false,
separatorRegex: undefined as RegExp | null | undefined,
fillArray(size: number, valueType: 'object' | 'array') {
const result: typeof valueType extends 'array' ? string[] : LooseObject[] =
[]
for (let i = 0; i < size; i += 1) {
let value: [] | LooseObject = []
if (valueType === 'object') {
value = {}
}
result.push(value)
}
return result
},
// helper: make bruteforce match objects spanning i to j, inclusive.
makeBruteforceMatch(i: number, j: number): BruteForceMatch {
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: MatchExtended, sequenceLength: number) {
const k = match.j
const estimatedMatch = estimateGuesses(match, this.password)
let pi = estimatedMatch.guesses as number
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: number) {
// 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: number) {
const optimalMatchSequence: MatchEstimated[] = []
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: MatchEstimated = this.optimal.m[k][sequenceLength]
optimalMatchSequence.unshift(match)
k = match.i - 1
sequenceLength -= 1
}
return optimalMatchSequence
},
}
export default {
// ------------------------------------------------------------------------------
// 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: string,
matches: MatchExtended[],
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',
) as any[]
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: MatchExtended, m2: MatchExtended) => 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: MatchExtended) => {
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: string, optimalSequenceLength: number) {
const passwordLength = password.length
let guesses = 0
if (password.length === 0) {
guesses = 1
} else {
guesses =
scoringHelper.optimal.g[passwordLength - 1][optimalSequenceLength]
}
return guesses
},
}

32
node_modules/@zxcvbn-ts/core/src/scoring/utils.ts generated vendored Normal file
View File

@@ -0,0 +1,32 @@
export default {
// binomial coefficients
// src: http://blog.plover.com/math/choose.html
nCk(n: number, k: number) {
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: number) {
if (n === 0) return 0
return Math.log(n) / Math.log(10) // IE doesn't support Math.log10 :(
},
log2(n: number) {
return Math.log(n) / Math.log(2)
},
factorial(num: number) {
let rval = 1
for (let i = 2; i <= num; i += 1) rval *= i
return rval
},
}

268
node_modules/@zxcvbn-ts/core/src/types.ts generated vendored Normal file
View File

@@ -0,0 +1,268 @@
import translationKeys from './data/translationKeys'
import l33tTableDefault from './data/l33tTable'
import { REGEXEN } from './data/const'
import { DictionaryReturn } from './matcher/dictionary/scoring'
import Matching from './Matching'
import { PasswordChanges } from './matcher/dictionary/variants/matching/unmunger/getCleanPasswords'
export type TranslationKeys = typeof translationKeys
export type L33tTableDefault = typeof l33tTableDefault
export interface LooseObject {
[key: string]: any
}
export type Pattern =
| 'dictionary'
| 'spatial'
| 'repeat'
| 'sequence'
| 'regex'
| 'date'
| 'bruteforce'
| 'separator'
| string
export type DictionaryNames =
| 'passwords'
| 'commonWords'
| 'firstnames'
| 'lastnames'
| 'wikipedia'
| 'userInputs'
export interface Match {
/**
* @description The name of the matcher
*/
pattern: Pattern
/**
* @description The start index of the token found in the password
*/
i: number
/**
@description The end index of the token found in the password
*/
j: number
/**
* @description The token found in the password
*/
token: string
[key: string]: any
}
export interface DictionaryMatch extends Match {
pattern: 'dictionary'
matchedWord: string
rank: number
dictionaryName: DictionaryNames
reversed: boolean
l33t: boolean
}
export interface L33tMatch extends DictionaryMatch {
subs: PasswordChanges[]
subDisplay: string
}
export interface SpatialMatch extends Match {
pattern: 'spatial'
graph: string
turns: number
shiftedCount: number
}
export interface RepeatMatch extends Match {
pattern: 'repeat'
baseToken: string | string[]
baseGuesses: number
repeatCount: number
}
export interface SequenceMatch extends Match {
pattern: 'sequence'
sequenceName: string
sequenceSpace: number
ascending: boolean
}
export interface RegexMatch extends Match {
pattern: 'regex'
regexName: keyof typeof REGEXEN
regexMatch: string[]
}
export interface DateMatch extends Match {
pattern: 'date'
separator: string
year: number
month: number
day: number
}
export interface BruteForceMatch extends Match {
pattern: 'bruteforce'
}
export interface SeparatorMatch extends Match {
pattern: 'separator'
}
export type MatchExtended =
| DictionaryMatch
| L33tMatch
| SpatialMatch
| RepeatMatch
| SequenceMatch
| RegexMatch
| DateMatch
| BruteForceMatch
| SeparatorMatch
| Match
export interface Estimate {
guesses: number
guessesLog10: number
baseGuesses?: number
uppercaseVariations?: number
l33tVariations?: number
}
export type MatchEstimated = MatchExtended & Estimate
export interface Optimal {
m: Match
pi: Match
g: Match
}
export interface CrackTimesSeconds {
onlineThrottling100PerHour: number
onlineNoThrottling10PerSecond: number
offlineSlowHashing1e4PerSecond: number
offlineFastHashing1e10PerSecond: number
}
export interface CrackTimesDisplay {
onlineThrottling100PerHour: string
onlineNoThrottling10PerSecond: string
offlineSlowHashing1e4PerSecond: string
offlineFastHashing1e10PerSecond: string
}
export interface FeedbackType {
warning: string | null
suggestions: string[]
}
export type OptionsL33tTable =
| L33tTableDefault
| {
[key: string]: string[]
}
export type OptionsDictionary = {
[key: string]: (string | number)[]
}
export interface OptionsGraphEntry {
[key: string]: (string | null)[]
}
export interface OptionsGraph {
[key: string]: OptionsGraphEntry
}
export interface OptionsType {
/**
* @description Defines an object with a key value match to translate the feedback given by this library. The default values are plain keys so that you can use your own i18n library. Already implemented language can be found with something like @zxcvbn-ts/language-en.
*/
translations?: TranslationKeys
/**
* @description Defines keyboard layouts as an object which are used to find sequences. Already implemented layouts can be found in @zxcvbn-ts/language-common
*/
graphs?: OptionsGraph
/**
* @description Define an object with l33t substitutions. For example that an "a" can be exchanged with a "4" or a "@".
*/
l33tTable?: OptionsL33tTable
/**
* @description Define dictionary that should be used to check against. The matcher will search the dictionaries for similar password with l33t speak and reversed words. The recommended sets are found in @zxcvbn-ts/language-common and @zxcvbn-ts/language-en.
*/
dictionary?: OptionsDictionary
/**
* @description Defines if the levenshtein algorithm should be used. This will be only used on the complete password and not on parts of it. This will decrease the calcTime a bit but will significantly improve the password check. The recommended sets are found in @zxcvbn-ts/language-common and @zxcvbn-ts/language-en.
* @default false
*/
useLevenshteinDistance?: boolean
/**
* @description Defines how many characters can be different to match a dictionary word with the levenshtein algorithm.
* @default 2
*/
levenshteinThreshold?: number
/**
* @description The l33t matcher will check how many characters can be exchanged with the l33t table. If they are to many it will decrease the calcTime significantly. So we cap it at a reasonable value by default which will probably already seems like a strong password anyway.
* @default 100
*/
l33tMaxSubstitutions?: number
/**
* @description Defines how many character of the password are checked. A password longer than the default are considered strong anyway, but it can be increased as pleased. Be aware that this could open some attack vectors.
* @default 256
*/
maxLength?: number
}
export interface RankedDictionary {
[key: string]: number
}
export interface RankedDictionaries {
[key: string]: RankedDictionary
}
export type DefaultFeedbackFunction = (
match: MatchEstimated,
isSoleMatch?: boolean,
) => FeedbackType | null
export type DefaultScoringFunction = (
match: MatchExtended | MatchEstimated,
) => number | DictionaryReturn
export interface MatchOptions {
password: string
/**
* @description This is the original Matcher so that one can use other matchers to define a baseGuess. An usage example is the repeat matcher
*/
omniMatch: Matching
}
export type MatchingType = new () => {
match({
password,
omniMatch,
}: MatchOptions): MatchExtended[] | Promise<MatchExtended[]>
}
export interface Matcher {
feedback: DefaultFeedbackFunction
scoring: DefaultScoringFunction
Matching: MatchingType
}
export interface Matchers {
[key: string]: Matcher
}
export type Score = 0 | 1 | 2 | 3 | 4
export interface ZxcvbnResult {
feedback: FeedbackType
crackTimesSeconds: CrackTimesSeconds
crackTimesDisplay: CrackTimesDisplay
score: Score
password: string
guesses: number
guessesLog10: number
sequence: MatchExtended[]
calcTime: number
}