// 评分:数学底座 + 最优匹配序列 DP + 每模式猜测数估计。
//
// 移植来源:dropbox/zxcvbn `src/scoring.coffee`(MIT, (c) Dropbox, Inc.)
//
// 关键点:上游的 guesses 全程是 JS 浮点数。本项目沿用 Double 语义——
// 长密码的 bruteforce guesses 会达到 10^n 乃至 Infinity,用 Int 会静默溢出,
// 而 `guesses_log10` 与破解时间只需要浮点精度。这也与 zxcvbn-rs 用 f64 一致。
// (公共 API 从早期草案的 Int 改为 Double,README 已同步说明。)
///|
/// 上游 `scoring.nCk`:组合数,官方向量 `nCk(33,7) = 4272048`。
pub fn nCk(n : Int, k : Int) -> Double {
if k > n {
return 0.0
}
if k == 0 {
return 1.0
}
let mut n_ = n
let mut r = 1.0
let mut d = 1
while d <= k {
r = r * n_.to_double()
r = r / d.to_double()
n_ -= 1
d += 1
}
r
}
///|
/// 上游 `scoring.log10`:`Math.log(n) / Math.log(10)`。
pub fn log10(n : Double) -> Double {
@math.log10(n)
}
///|
/// 上游 `scoring.log2`:`Math.log(n) / Math.log(2)`。
pub fn log2(n : Double) -> Double {
@math.log2(n)
}
///|
/// 上游 `scoring.factorial`。
pub fn factorial(n : Int) -> Double {
if n < 2 {
return 1.0
}
let mut f = 1.0
let mut i = 2
while i <= n {
f = f * i.to_double()
i += 1
}
f
}
///|
/// 上游 `bruteforce_cardinality`。
let bruteforce_cardinality : Double = 10.0
///|
/// 上游 `min_guesses_before_growing_sequence`:DP 里长度惩罚项的底数。
let min_guesses_before_growing_sequence : Double = 10000.0
///|
/// 上游 `min_submatch_guesses_single_char`。
let min_submatch_guesses_single_char : Double = 10.0
///|
/// 上游 `min_submatch_guesses_multi_char`。
let min_submatch_guesses_multi_char : Double = 50.0
///|
/// 上游 `min_year_space`。
let min_year_space : Double = 20.0
///|
/// 上游 JS 的 `Number.MAX_VALUE`(1.7976931348623157e308)。
/// bruteforce guesses 在长度 >309 时会上溢到 Infinity,此时回落到该值。
let max_double : Double = 1.7976931348623157e308
///|
/// 上游 `most_guessable_match_sequence` 的结果。
pub struct MatchSequence {
password : String
guesses : Double
guesses_log10 : Double
sequence : Array[Match]
} derive(Eq, Debug)
///|
pub extend MatchSequence with Eq::{equal, not_equal}
///|
pub extend MatchSequence with @debug.Debug::{to_repr}
// ---------------------------------------------------------------------------
// 最优匹配序列 DP
// ---------------------------------------------------------------------------
///|
/// 上游 `scoring.most_guessable_match_sequence`。
///
/// 把一组可能重叠的匹配,转换为**非重叠**且 guesses 最小的序列。
/// 目标函数(与上游一致):
///
/// ```text
/// g = l! * Prod(m.guesses for m in sequence) + D^(l - 1)
/// ```
///
/// 其中 `l` 为序列长度,`l!` 是 l 个模式的排列数,`D^(l-1)` 是长度惩罚
/// (攻击者会先试更短的序列)。
///
/// `exclude_additive` 仅供上游测试使用(测试里会把加性惩罚关掉)。
pub fn most_guessable_match_sequence(
password : String,
matches : Array[Match],
reference_year : Int,
exclude_additive? : Bool = false,
) -> MatchSequence {
let chars = password.to_array()
let n = chars.length()
// 角落:空密码
if n == 0 {
return { password, guesses: 1.0, guesses_log10: 0.0, sequence: [], }
}
// 按结束下标 j 分区;为了确定性,每个分区按 i 排序
let matches_by_j : Array[Array[Match]] = Array::makei(n, _ => [])
for m in matches {
let k = m.end_index()
if k < n {
matches_by_j[k].push(m)
}
}
for lst in matches_by_j {
lst.sort_by((a, b) => a.start_index() - b.start_index())
}
// optimal.m[k][l]:覆盖密码 [0..k] 前缀的最优长度-l 序列的最后一个匹配
let optimal_m : Array[Map[Int, Match]] = Array::makei(n, _ => Map([]))
// optimal.pi[k][l]:上述序列的 guesses 乘积项
let optimal_pi : Array[Map[Int, Double]] = Array::makei(n, _ => Map([]))
// optimal.g[k][l]:上述序列的整体度量
let optimal_g : Array[Map[Int, Double]] = Array::makei(n, _ => Map([]))
// --- helper: 考虑"以 m 结尾的长度-l 序列"是否更优 -------------
let update : (Match, Int) -> Unit = (m, l) => {
let k = m.end_index()
let mut pi = estimate_guesses(m, chars.length(), reference_year)
if l > 1 {
// l-1 序列必须结束在 m.i - 1 上
pi = pi * optimal_pi[m.start_index() - 1][l - 1]
}
let mut g = factorial(l) * pi
if !exclude_additive {
g = g +
@math.pow(min_guesses_before_growing_sequence, (l - 1).to_double())
}
// 若同 k 上已有 l' <= l 且 g' <= g 的序列,本序列无价值
let pairs : Array[(Int, Double)] = Array::from_iter(optimal_g[k].iter())
let mut skip = false
let mut idx = 0
while idx < pairs.length() && !skip {
let (competing_l, competing_g) = pairs[idx]
if competing_l <= l && competing_g <= g {
skip = true
}
idx += 1
}
if !skip {
optimal_g[k][l] = g
optimal_m[k][l] = m
optimal_pi[k][l] = pi
}
}
// --- helper: 构造覆盖 [i, j] 的 bruteforce 匹配 -----------------
let make_bruteforce_match : (Int, Int) -> Match = (i, j) => {
Match::Bruteforce({
i,
j,
token: String::from_array(chars[i:j + 1]),
guesses: 0.0,
guesses_log10: 0.0,
})
}
// --- helper: 评估以 k 结尾的 bruteforce 匹配 -------------------
let bruteforce_update : (Int) -> Unit = k => {
// 单个 bruteforce 匹配覆盖整个 [0, k] 前缀
update(make_bruteforce_match(0, k), 1)
let mut i = 1
while i <= k {
let m = make_bruteforce_match(i, k)
// 尝试把它接到 optimal[i-1] 的各序列后面
let pairs : Array[(Int, Match)] = Array::from_iter(
optimal_m[i - 1].iter(),
)
for pair in pairs {
let (l, last_m) = pair
// 角落:最优序列不会出现两个相邻的 bruteforce 匹配——单个覆盖同一区间的
// bruteforce 猜测数更少、长度更短,严格更优,所以这些情况可以跳过。
if last_m.pattern() != "bruteforce" {
update(m, l + 1)
}
}
i += 1
}
}
// --- helper: 从终点回溯构造最终序列 ---------------------------
let unwind : (Int) -> Array[Match] = end => {
let seq : Array[Match] = []
let mut k = end - 1
// 取整体 g 最小的长度 l。打平时 JS 对象整数键按升序遍历,
// 于是取到较小的 l;这里显式排序以复现该行为。
let keys : Array[Int] = Array::from_iter(optimal_g[k].keys())
keys.sort()
let mut l = -1
let mut best_g = 0.0
let mut first = true
for key in keys {
let g = optimal_g[k][key]
if first || g < best_g {
l = key
best_g = g
first = false
}
}
while k >= 0 && l > 0 {
match optimal_m[k].get(l) {
Some(m) => {
seq.push(m)
k = m.start_index() - 1
l -= 1
}
// 正常不可达;防御性跳出避免越界
None => k = -1
}
}
seq.rev_in_place()
seq
}
let mut k = 0
while k < n {
for m in matches_by_j[k] {
if m.start_index() > 0 {
let pairs : Array[(Int, Match)] = Array::from_iter(
optimal_m[m.start_index() - 1].iter(),
)
for pair in pairs {
let (l, _last_m) = pair
update(m, l + 1)
}
} else {
update(m, 1)
}
}
bruteforce_update(k)
k += 1
}
let sequence = unwind(n)
let optimal_l = sequence.length()
let guesses = optimal_g[n - 1][optimal_l]
{ password, guesses, guesses_log10: log10(guesses), sequence, }
}
// ---------------------------------------------------------------------------
// 猜测数估计
// ---------------------------------------------------------------------------
///|
/// 上游 `scoring.estimate_guesses`:按模式分派并缓存。
///
/// `password_length` 是密码的**标量值**长度(上游用 UTF-16 码元长度,
/// 这是本项目有意保留的差异,见 README)。
fn estimate_guesses(
m : Match,
password_length : Int,
reference_year : Int,
) -> Double {
// 匹配的猜测数不会变,缓存之
if m.guesses() != 0.0 {
return m.guesses()
}
let mut min_guesses = 1.0
let span = m.span_length()
if span < password_length {
min_guesses = if span == 1 {
min_submatch_guesses_single_char
} else {
min_submatch_guesses_multi_char
}
}
let guesses = match m {
Bruteforce(bm) => bruteforce_guesses(bm)
Dictionary(dm) => dictionary_guesses(dm)
Spatial(sm) => spatial_guesses(sm)
Repeat(rm) => repeat_guesses(rm)
Sequence(sm) => sequence_guesses(sm)
Regex(rm) => regex_guesses(rm, reference_year)
Date(dm) => date_guesses(dm, reference_year)
}
let result = if guesses > min_guesses { guesses } else { min_guesses }
m.set_guesses(result)
m.set_guesses_log10(log10(result))
result
}
///|
/// 上游 `bruteforce_guesses`。
fn bruteforce_guesses(m : BruteforceMatch) -> Double {
let len = m.token.to_array().length()
let raw = @math.pow(bruteforce_cardinality, len.to_double())
// 上溢时回落到 JS 的 Number.MAX_VALUE
let guesses = if raw > max_double { max_double } else { raw }
// 小细节:让 bruteforce 猜测数至少比最小的 submatch 猜测数大 1,
// 这样同一区间上的非 bruteforce submatch 优先
let min_guesses = if len == 1 {
min_submatch_guesses_single_char + 1.0
} else {
min_submatch_guesses_multi_char + 1.0
}
if guesses > min_guesses {
guesses
} else {
min_guesses
}
}
///|
/// 上游 `repeat_guesses`。
fn repeat_guesses(m : RepeatMatch) -> Double {
m.base_guesses * m.repeat_count.to_double()
}
///|
/// 上游 `sequence_guesses`。
fn sequence_guesses(m : SequenceMatch) -> Double {
let first_chr = m.token.to_array()[0]
let obvious_start = first_chr == 'a' ||
first_chr == 'A' ||
first_chr == 'z' ||
first_chr == 'Z' ||
first_chr == '0' ||
first_chr == '1' ||
first_chr == '9'
let mut base_guesses = if obvious_start {
// 明显的起始点猜得更少
4.0
} else if is_digit(first_chr) {
10.0
} else {
// 大写序列本可以给更高 base;对大小写都给 26 更保守
26.0
}
if !m.ascending {
// 除了每个升序序列,还要试降序序列 => 2 倍猜测数
base_guesses = base_guesses * 2.0
}
base_guesses * m.token.to_array().length().to_double()
}
///|
/// 上游 `regex_guesses` 的 `recent_year` 分支。
fn regex_guesses(m : RegexMatch, reference_year : Int) -> Double {
let year = digits_to_int(m.token.to_array())
let year_space = abs_int(year - reference_year).to_double()
if year_space > min_year_space {
year_space
} else {
min_year_space
}
}
///|
/// 上游 `date_guesses`。
fn date_guesses(m : DateMatch, reference_year : Int) -> Double {
// base guesses = (离参考年的距离) × 一年天数
let year_space = abs_int(m.year - reference_year).to_double()
let clamped = if year_space > min_year_space {
year_space
} else {
min_year_space
}
let mut guesses = clamped * 365.0
// 分隔符选择再乘 4(约 4 种选择)
if m.separator != "" {
guesses = guesses * 4.0
}
guesses
}
///|
/// 上游 `spatial_guesses`。
fn spatial_guesses(m : SpatialMatch) -> Double {
let (s, d) = if m.graph == "qwerty" || m.graph == "dvorak" {
(keyboard_starting_positions, keyboard_average_degree)
} else {
(keypad_starting_positions, keypad_average_degree)
}
let mut guesses = 0.0
let l = m.token.to_array().length()
let t = m.turns
// 估计长度为 L、转向数不超过 t 的模式数量
let mut i = 2
while i <= l {
let possible_turns = if t < i - 1 { t } else { i - 1 }
let mut j = 1
while j <= possible_turns {
guesses = guesses +
nCk(i - 1, j - 1) * s.to_double() * @math.pow(d, j.to_double())
j += 1
}
i += 1
}
// 移位键的额外猜测(% 代替 5、A 代替 a),与 l33t 替换的数学同构
if m.shifted_count > 0 {
let s_count = m.shifted_count
let u = l - s_count
if s_count == 0 || u == 0 {
guesses = guesses * 2.0
} else {
let mut shifted_variations = 0.0
let limit = if s_count < u { s_count } else { u }
let mut i = 1
while i <= limit {
shifted_variations = shifted_variations + nCk(s_count + u, i)
i += 1
}
guesses = guesses * shifted_variations
}
}
guesses
}
///|
/// 上游 `dictionary_guesses`。
fn dictionary_guesses(m : DictionaryMatch) -> Double {
let base_guesses = m.rank.to_double()
let uppercase = uppercase_variations(m.token)
let l33t = l33t_variations(m)
let reversed_variations = if m.reversed { 2.0 } else { 1.0 }
base_guesses * uppercase * l33t * reversed_variations
}
///|
/// 上游 `uppercase_variations`。
///
/// 四个上游正则:
/// * `START_UPPER = /^[A-Z][^A-Z]+$/`
/// * `END_UPPER = /^[^A-Z]+[A-Z]$/`
/// * `ALL_UPPER = /^[^a-z]+$/`
/// * `ALL_LOWER = /^[^A-Z]+$/`
fn uppercase_variations(word : String) -> Double {
let chars = word.to_array()
// 全小写(或没有大写字母)不增加搜索空间
if all_non_upper(chars) {
return 1.0
}
if word.to_lower() == word {
return 1.0
}
// 首字母大写是最常见的做法,只让搜索空间翻倍(未大写 + 首大写);
// 全大写和尾大写也够常见,同样低估为 2 倍
if is_start_upper(chars) || is_end_upper(chars) || all_non_lower(chars) {
return 2.0
}
// 否则计算"最多把 U 个大写字母转小写"的方案数;
// 若大写比小写还多(如 PASSwORD),反过来算"最多把 L 个小写转大写"
let u = count_if(chars, is_upper)
let l = count_if(chars, is_lower)
let mut variations = 0.0
let limit = if u < l { u } else { l }
let mut i = 1
while i <= limit {
variations = variations + nCk(u + l, i)
i += 1
}
variations
}
///|
/// 上游 `ALL_LOWER`:所有字符都不是 ASCII 大写(且至少一个字符)。
fn all_non_upper(chars : Array[Char]) -> Bool {
if chars.length() == 0 {
return false
}
for c in chars {
if is_upper(c) {
return false
}
}
true
}
///|
/// 上游 `ALL_UPPER`:所有字符都不是 ASCII 小写(且至少一个字符)。
fn all_non_lower(chars : Array[Char]) -> Bool {
if chars.length() == 0 {
return false
}
for c in chars {
if is_lower(c) {
return false
}
}
true
}
///|
/// 上游 `START_UPPER`:首字符大写,其余都不是大写(至少 2 个字符)。
fn is_start_upper(chars : Array[Char]) -> Bool {
if chars.length() < 2 {
return false
}
if !is_upper(chars[0]) {
return false
}
for c in chars[1:chars.length()] {
if is_upper(c) {
return false
}
}
true
}
///|
/// 上游 `END_UPPER`:末字符大写,前面都不是大写(至少 2 个字符)。
fn is_end_upper(chars : Array[Char]) -> Bool {
let n = chars.length()
if n < 2 {
return false
}
if !is_upper(chars[n - 1]) {
return false
}
for c in chars[0:n - 1] {
if is_upper(c) {
return false
}
}
true
}
///|
/// 上游 `l33t_variations`。
fn l33t_variations(m : DictionaryMatch) -> Double {
if !m.l33t {
return 1.0
}
let mut variations = 1.0
// 先转小写再统计:大小写不该影响 l33t 计算
let chrs = m.token.to_lower().to_array()
for pair in m.sub {
let (subbed, unsubbed) = pair
let s = count_if(chrs, c => c == subbed)
let u = count_if(chrs, c => c == unsubbed)
if s == 0 || u == 0 {
// 对该替换,密码要么全替换(444)要么全不替换(aaa),
// 视为搜索空间翻倍
variations = variations * 2.0
} else {
// 以 aa44a 为例:U=3, S=2,攻击者要试"不替换 + 替 1 个 + 替 2 个"
let limit = if u < s { u } else { s }
let mut possibilities = 0.0
let mut i = 1
while i <= limit {
possibilities = possibilities + nCk(u + s, i)
i += 1
}
variations = variations * possibilities
}
}
variations
}
///|
/// 统计满足谓词的字符个数。
fn count_if(chars : Array[Char], pred : (Char) -> Bool) -> Int {
let mut n = 0
for c in chars {
if pred(c) {
n += 1
}
}
n
}