Files
SnowLuma/.pnpm-store/v11/files/25/a475a12fc9abe72d7bc45a964c69f9f6f20574ce9f7cb0dc37b2b9ab01355585f7a7ac067a3897ebeb69709e52326ba00aae98df0e647cb0b798d3ea244fc8
T
TanTan 2a69b23b47
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
docs: 仓库链接指向本镜像仓库
2026-11-13 09:27:07 +08:00

221 lines
9.5 KiB
Plaintext

import utils from './utils.mjs';
import estimateGuesses from './estimate.mjs';
import { MIN_GUESSES_BEFORE_GROWING_SEQUENCE } from '../data/const.mjs';
class Scoring {
constructor(options) {
this.options = options;
this.password = '';
this.optimal = {};
this.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 bestMatches is better (fewer guesses)
// than previously encountered sequences, updating state if so.
// eslint-disable-next-line max-statements
update(match, sequenceLength) {
const k = match.j;
const estimatedMatch = estimateGuesses(this.options, match, this.password);
let pi = estimatedMatch.guesses;
if (sequenceLength > 1) {
// we're considering a length-sequenceLength sequence ending with match bestMatches:
// obtain the product term in the minimization function by multiplying bestMatches's guesses
// by the product of the length-(sequenceLength-1)
// sequence ending just before bestMatches, at bestMatches.i - 1.
pi *= this.optimal.guessesProduct[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;
const competingG = this.optimal.totalGuesses[k];
Object.keys(competingG).forEach(competingPatternLengthStr => {
const competingPatternLength = parseInt(competingPatternLengthStr, 10);
const competingMetricMatch = competingG[competingPatternLength];
if (competingPatternLength <= sequenceLength) {
if (competingMetricMatch <= g) {
shouldSkip = true;
}
}
});
if (!shouldSkip) {
// this sequence might be part of the final optimal sequence.
this.optimal.totalGuesses[k][sequenceLength] = g;
this.optimal.bestMatches[k][sequenceLength] = estimatedMatch;
this.optimal.guessesProduct[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.bestMatches[i - 1];
Object.keys(tmp).forEach(sequenceLengthStr => {
const sequenceLength = parseInt(sequenceLengthStr, 10);
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 bestMatches to this length-sequenceLength sequence.
this.update(match, sequenceLength + 1);
}
});
}
}
// helper: step backwards through optimal.bestMatches 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.totalGuesses[k];
// safety check for empty passwords
if (temp) {
Object.keys(temp).forEach(candidateSequenceLengthStr => {
const candidateSequenceLength = parseInt(candidateSequenceLengthStr, 10);
const candidateMetricMatch = temp[candidateSequenceLength];
if (candidateMetricMatch < g) {
sequenceLength = candidateSequenceLength;
g = candidateMetricMatch;
}
});
}
while (k >= 0) {
const match = this.optimal.bestMatches[k][sequenceLength];
optimalMatchSequence.unshift(match);
k = match.i - 1;
sequenceLength -= 1;
}
return optimalMatchSequence;
}
// ------------------------------------------------------------------------------
// 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:
//
// totalGuesses = 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) {
this.password = password;
this.excludeAdditive = excludeAdditive;
const passwordLength = password.length;
// partition matches into sublists according to ending index j
let matchesByCoordinateJ = this.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));
this.optimal = {
// optimal.bestMatches[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.bestMatches[k][sequenceLength] is undefined.
bestMatches: this.fillArray(passwordLength, 'object'),
// same structure as optimal.bestMatches -- holds the product term Prod(bestMatches.guesses for bestMatches in sequence).
// optimal.guessesProduct allows for fast (non-looping) updates to the minimization function.
guessesProduct: this.fillArray(passwordLength, 'object'),
// same structure as optimal.bestMatches -- holds the overall metric.
totalGuesses: this.fillArray(passwordLength, 'object')
};
for (let k = 0; k < passwordLength; k += 1) {
matchesByCoordinateJ[k].forEach(match => {
if (match.i > 0) {
const prevM = this.optimal.bestMatches[match.i - 1];
Object.keys(prevM).forEach(sequenceLengthStr => {
const sequenceLength = parseInt(sequenceLengthStr, 10);
this.update(match, sequenceLength + 1);
});
} else {
this.update(match, 1);
}
});
this.bruteforceUpdate(k);
}
const optimalMatchSequence = this.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;
if (password.length === 0) {
return 1;
} else {
return this.optimal.totalGuesses[passwordLength - 1][optimalSequenceLength];
}
}
}
export { Scoring as default };
//# sourceMappingURL=index.mjs.map