@misok/password-checker 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/fixtures/bloom-meta.json +1 -0
- package/fixtures/blooms.generated.json +24 -0
- package/package.json +48 -0
- package/src/index.d.ts +64 -0
- package/src/index.js +449 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
const I18N = {
|
|
2
|
+
fi: {
|
|
3
|
+
labels: { weak: 'Heikko', moderate: 'Kohtalainen', good: 'Hyvä', strong: 'Vahva', dangerous: 'VAARALLINEN' },
|
|
4
|
+
tips: {
|
|
5
|
+
empty: 'Kirjoita salasana arvioitavaksi.',
|
|
6
|
+
repetition: 'Salasana sisältää paljon toistoa tai vähän eri merkkejä.',
|
|
7
|
+
sequence: 'Vältä näppäimistö- tai numerojärjestyksiä.',
|
|
8
|
+
short: 'Lyhyt salasana on helpompi murtaa. Tavoittele vähintään 12 merkkiä.',
|
|
9
|
+
phrase: 'Pitkäkin salasanalauseke kannattaa maustaa satunnaisella osalla, jos se koostuu vain yleisistä sanoista.',
|
|
10
|
+
year: 'Vältä vuosilukuja (esim. 2026) salasanan osana.',
|
|
11
|
+
pwned: 'Salasana löytyi tunnetuista tietovuodoista (HIBP). Älä käytä tätä salasanaa.'
|
|
12
|
+
},
|
|
13
|
+
errors: { noDecoder: 'Base64-dekooderia ei löytynyt tästä ajoympäristöstä.' }
|
|
14
|
+
},
|
|
15
|
+
en: {
|
|
16
|
+
labels: { weak: 'Weak', moderate: 'Moderate', good: 'Good', strong: 'Strong', dangerous: 'DANGEROUS' },
|
|
17
|
+
tips: {
|
|
18
|
+
empty: 'Enter a password to analyze.',
|
|
19
|
+
repetition: 'Password contains heavy repetition or too few unique characters.',
|
|
20
|
+
sequence: 'Avoid keyboard patterns and number sequences.',
|
|
21
|
+
short: 'Short passwords are easier to crack. Aim for at least 12 characters.',
|
|
22
|
+
phrase: 'Even long passphrases should include a random element when built from very common words.',
|
|
23
|
+
year: 'Avoid years (e.g. 2026) as part of a password.',
|
|
24
|
+
pwned: 'Found in known data breaches (HIBP). Do not use this password.'
|
|
25
|
+
},
|
|
26
|
+
errors: { noDecoder: 'No base64 decoder available in this runtime.' }
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ASCII_LETTER_RUN_RE = /[A-Za-z]+/g;
|
|
31
|
+
|
|
32
|
+
export class PasswordDefenseCore {
|
|
33
|
+
constructor(cfg = {}) {
|
|
34
|
+
this.defaultLanguage = cfg.defaultLanguage || 'en';
|
|
35
|
+
this.locale = cfg.locale || 'en';
|
|
36
|
+
this.activeLanguages = cfg.activeLanguages || [this.defaultLanguage];
|
|
37
|
+
this.languages = cfg.languages || cfg.blooms || {};
|
|
38
|
+
this.hibp = {
|
|
39
|
+
enabled: !!cfg.hibp?.enabled,
|
|
40
|
+
endpoint: cfg.hibp?.endpoint || 'https://api.pwnedpasswords.com/range/',
|
|
41
|
+
timeoutMs: Number(cfg.hibp?.timeoutMs || 6000)
|
|
42
|
+
};
|
|
43
|
+
this.state = {};
|
|
44
|
+
for (const [lang, lc] of Object.entries(this.languages)) {
|
|
45
|
+
const size = Number(lc.size);
|
|
46
|
+
const hashes = Number(lc.hashes);
|
|
47
|
+
const minTokenLength = Number(lc.minTokenLength);
|
|
48
|
+
this.state[lang] = {
|
|
49
|
+
bloomReady: false,
|
|
50
|
+
bloomBytes: null,
|
|
51
|
+
size: Number.isInteger(size) && size >= 1024 ? size : 120000,
|
|
52
|
+
hashes: Number.isInteger(hashes) && hashes >= 1 && hashes <= 32 ? hashes : 7,
|
|
53
|
+
minTokenLength: Number.isInteger(minTokenLength) && minTokenLength >= 1 ? minTokenLength : 3,
|
|
54
|
+
data: typeof lc.data === 'string' ? lc.data : ''
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
resolveLocale(locale) {
|
|
60
|
+
return I18N[locale] ? locale : 'en';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
t(path, locale = this.locale) {
|
|
64
|
+
const dict = I18N[this.resolveLocale(locale)] || I18N.en;
|
|
65
|
+
return path.split('.').reduce((acc, k) => (acc && acc[k] !== undefined ? acc[k] : null), dict) || path;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setLocale(locale = 'en') {
|
|
69
|
+
this.locale = this.resolveLocale(locale);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
normalizeToken(token) {
|
|
73
|
+
return String(token || '').normalize('NFKC').toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setActiveLanguages(languages = []) {
|
|
77
|
+
if (!Array.isArray(languages) || languages.length === 0) {
|
|
78
|
+
this.activeLanguages = [this.defaultLanguage];
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.activeLanguages = languages.filter((l) => this.state[l]);
|
|
82
|
+
if (this.activeLanguages.length === 0) this.activeLanguages = [this.defaultLanguage];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
decodeBloomBase64(b64) {
|
|
86
|
+
const clean = (b64 || '').replace(/[^A-Za-z0-9+/=_-]/g, '').replace(/-/g, '+').replace(/_/g, '/');
|
|
87
|
+
if (!clean) return null;
|
|
88
|
+
const padded = clean + '='.repeat((4 - (clean.length % 4)) % 4);
|
|
89
|
+
|
|
90
|
+
if (typeof Buffer !== 'undefined') {
|
|
91
|
+
return new Uint8Array(Buffer.from(padded, 'base64'));
|
|
92
|
+
}
|
|
93
|
+
if (typeof atob !== 'undefined') {
|
|
94
|
+
const bin = atob(padded);
|
|
95
|
+
const out = new Uint8Array(bin.length);
|
|
96
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(this.t('errors.noDecoder'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ensureBloomLoaded(lang) {
|
|
103
|
+
const s = this.state[lang];
|
|
104
|
+
if (!s || s.bloomReady) return;
|
|
105
|
+
s.bloomBytes = this.decodeBloomBase64(s.data);
|
|
106
|
+
if (s.bloomBytes) {
|
|
107
|
+
const expectedBytes = Math.ceil(s.size / 8);
|
|
108
|
+
if (s.bloomBytes.length !== expectedBytes) {
|
|
109
|
+
throw new Error(`Bloom payload size mismatch for '${lang}': expected ${expectedBytes} bytes, got ${s.bloomBytes.length}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
s.bloomReady = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
hash1FNV1a(word) {
|
|
116
|
+
let h = 0x811c9dc5;
|
|
117
|
+
for (let i = 0; i < word.length; i++) {
|
|
118
|
+
h ^= word.charCodeAt(i);
|
|
119
|
+
h = Math.imul(h, 0x01000193);
|
|
120
|
+
}
|
|
121
|
+
return h >>> 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
hash2DJB2(word) {
|
|
125
|
+
let h = 5381;
|
|
126
|
+
for (let i = 0; i < word.length; i++) {
|
|
127
|
+
h = ((h << 5) + h + word.charCodeAt(i)) >>> 0;
|
|
128
|
+
}
|
|
129
|
+
return h >>> 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
checkBloomForLanguage(word, lang) {
|
|
133
|
+
const s = this.state[lang];
|
|
134
|
+
if (!s) return false;
|
|
135
|
+
if (!word || word.length < s.minTokenLength) return false;
|
|
136
|
+
this.ensureBloomLoaded(lang);
|
|
137
|
+
if (!s.bloomBytes || s.bloomBytes.length === 0) return false;
|
|
138
|
+
|
|
139
|
+
const w = this.normalizeToken(word);
|
|
140
|
+
const h1 = this.hash1FNV1a(w);
|
|
141
|
+
let h2 = this.hash2DJB2(w);
|
|
142
|
+
if (h2 === 0) h2 = 1;
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < s.hashes; i++) {
|
|
145
|
+
const mixed = (h1 + Math.imul(i, h2) + Math.imul(i, i + 3)) >>> 0;
|
|
146
|
+
const hash = mixed % s.size;
|
|
147
|
+
const byteIdx = Math.floor(hash / 8);
|
|
148
|
+
const bitIdx = hash % 8;
|
|
149
|
+
if (!s.bloomBytes[byteIdx] || !(s.bloomBytes[byteIdx] & (1 << bitIdx))) return false;
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
checkBloom(word, options = {}) {
|
|
155
|
+
const langs = options.languages || this.activeLanguages;
|
|
156
|
+
for (const lang of langs) {
|
|
157
|
+
if (this.checkBloomForLanguage(word, lang)) return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
createUnicodeRegex(source, flags) {
|
|
163
|
+
try {
|
|
164
|
+
return new RegExp(source, flags);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getLetterRuns(input) {
|
|
171
|
+
const source = String(input || '');
|
|
172
|
+
const matches = [];
|
|
173
|
+
const re = this.createUnicodeRegex('\\p{L}+', 'gu') || new RegExp(ASCII_LETTER_RUN_RE);
|
|
174
|
+
let match;
|
|
175
|
+
while ((match = re.exec(source)) !== null) {
|
|
176
|
+
matches.push({ text: match[0], start: match.index });
|
|
177
|
+
}
|
|
178
|
+
return matches;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
findDictionaryMatches(password, languages) {
|
|
182
|
+
const matches = [];
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
const letterRuns = this.getLetterRuns(password);
|
|
185
|
+
|
|
186
|
+
for (const run of letterRuns) {
|
|
187
|
+
const normalized = this.normalizeToken(run.text);
|
|
188
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
189
|
+
const maxLen = Math.min(20, normalized.length - i);
|
|
190
|
+
for (let len = maxLen; len >= 3; len--) {
|
|
191
|
+
const part = normalized.substring(i, i + len);
|
|
192
|
+
if (part.length < 3) continue;
|
|
193
|
+
if (!this.checkBloom(part, { languages })) continue;
|
|
194
|
+
|
|
195
|
+
const start = run.start + i;
|
|
196
|
+
const key = `${start}:${part}`;
|
|
197
|
+
if (!seen.has(key)) {
|
|
198
|
+
matches.push({ part, start, len });
|
|
199
|
+
seen.add(key);
|
|
200
|
+
}
|
|
201
|
+
i += len - 1;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return matches.sort((a, b) => a.start - b.start);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
assessPassphrase(password, matchedParts = []) {
|
|
211
|
+
if (matchedParts.length < 2) {
|
|
212
|
+
return { qualifies: false, words: 0, separators: 0, coverage: 0, bonus: 0, strategy: matchedParts.length === 1 ? 'word_based' : 'random' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const uniqueWords = [...new Set(matchedParts.map((m) => m.part))];
|
|
216
|
+
const separatorRe = this.createUnicodeRegex('[^0-9\\p{L}]+', 'gu');
|
|
217
|
+
const separators = separatorRe ? (password.match(separatorRe) || []).length : (password.match(/[^0-9A-Za-z]+/g) || []).length;
|
|
218
|
+
const letterChars = this.getLetterRuns(password).reduce((sum, run) => sum + run.text.length, 0);
|
|
219
|
+
const coveredChars = matchedParts.reduce((sum, match) => sum + match.len, 0);
|
|
220
|
+
const coverage = letterChars > 0 ? coveredChars / letterChars : 0;
|
|
221
|
+
const longEnough = password.length >= 16;
|
|
222
|
+
const enoughWords = uniqueWords.length >= 3 || (uniqueWords.length >= 2 && password.length >= 24 && separators >= 1);
|
|
223
|
+
const qualifies = longEnough && enoughWords && coverage >= 0.6;
|
|
224
|
+
|
|
225
|
+
let bonus = 0;
|
|
226
|
+
if (qualifies) {
|
|
227
|
+
// 3-word passphrase gets baseline uplift; 4+ words doubles the phrase uplift.
|
|
228
|
+
bonus = 18;
|
|
229
|
+
if (uniqueWords.length >= 4) bonus *= 2;
|
|
230
|
+
if (password.length >= 24) bonus += 8;
|
|
231
|
+
if (separators >= 2) bonus += 4;
|
|
232
|
+
bonus = Math.min(60, bonus);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let strategy = 'mixed';
|
|
236
|
+
if (qualifies) strategy = 'passphrase';
|
|
237
|
+
else if (coverage >= 0.75) strategy = 'word_based';
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
qualifies,
|
|
241
|
+
words: uniqueWords.length,
|
|
242
|
+
separators,
|
|
243
|
+
coverage,
|
|
244
|
+
bonus,
|
|
245
|
+
strategy
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async sha1Hex(input) {
|
|
250
|
+
const data = new TextEncoder().encode(input);
|
|
251
|
+
if (!globalThis.crypto?.subtle) {
|
|
252
|
+
throw new Error('WebCrypto subtle API not available for SHA-1');
|
|
253
|
+
}
|
|
254
|
+
const hash = await globalThis.crypto.subtle.digest('SHA-1', data);
|
|
255
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async checkPwned(password, options = {}) {
|
|
259
|
+
const enabled = options.enabled ?? this.hibp.enabled;
|
|
260
|
+
if (!enabled) return { enabled: false, pwned: false };
|
|
261
|
+
if (!password || password.length < 1) return { enabled: true, pwned: false, count: 0 };
|
|
262
|
+
|
|
263
|
+
const hashHex = await this.sha1Hex(password);
|
|
264
|
+
const prefix = hashHex.slice(0, 5);
|
|
265
|
+
const suffix = hashHex.slice(5);
|
|
266
|
+
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || this.hibp.timeoutMs);
|
|
269
|
+
try {
|
|
270
|
+
const endpoint = options.endpoint || this.hibp.endpoint;
|
|
271
|
+
const res = await fetch(`${endpoint}${prefix}`, { signal: controller.signal });
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
return { enabled: true, pwned: false, count: 0, error: `hibp_http_${res.status}` };
|
|
274
|
+
}
|
|
275
|
+
const text = await res.text();
|
|
276
|
+
const line = text.split('\n').find((l) => l.toUpperCase().startsWith(suffix));
|
|
277
|
+
if (!line) return { enabled: true, pwned: false, count: 0 };
|
|
278
|
+
const count = Number((line.split(':')[1] || '0').trim()) || 0;
|
|
279
|
+
return { enabled: true, pwned: count > 0, count };
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return { enabled: true, pwned: false, count: 0, error: err?.name || String(err) };
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(timeout);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async analyzeAsync(pw, options = {}) {
|
|
288
|
+
const locale = this.resolveLocale(options.locale || this.locale);
|
|
289
|
+
const base = this.analyze(pw, { ...options, locale });
|
|
290
|
+
const hibp = await this.checkPwned(pw, options.hibp || {});
|
|
291
|
+
if (hibp.pwned) {
|
|
292
|
+
return {
|
|
293
|
+
...base,
|
|
294
|
+
score: 0,
|
|
295
|
+
label: this.t('labels.dangerous', locale),
|
|
296
|
+
labelKey: 'dangerous',
|
|
297
|
+
confidence: 'high',
|
|
298
|
+
tips: [...base.tips, this.t('tips.pwned', locale)],
|
|
299
|
+
riskFlags: [...new Set([...(base.riskFlags || []), 'hibp_breached'])],
|
|
300
|
+
scoreBreakdown: {
|
|
301
|
+
...(base.scoreBreakdown || {}),
|
|
302
|
+
final: 0,
|
|
303
|
+
hibpOverride: true
|
|
304
|
+
},
|
|
305
|
+
hibp
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return { ...base, hibp };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
analyze(pw, options = {}) {
|
|
312
|
+
const locale = this.resolveLocale(options.locale || this.locale);
|
|
313
|
+
if (!pw) return { score: 0, label: this.t('labels.weak', locale), tips: [this.t('tips.empty', locale)] };
|
|
314
|
+
|
|
315
|
+
let charsetSize = 0;
|
|
316
|
+
if (/[a-z]/.test(pw)) charsetSize += 26;
|
|
317
|
+
if (/[A-Z]/.test(pw)) charsetSize += 26;
|
|
318
|
+
if (/[0-9]/.test(pw)) charsetSize += 10;
|
|
319
|
+
if (/[^A-Za-z0-9]/.test(pw)) charsetSize += 33;
|
|
320
|
+
const baselineScore = (pw.length * Math.log2(charsetSize || 1) / 80) * 100;
|
|
321
|
+
let score = baselineScore;
|
|
322
|
+
|
|
323
|
+
let penalty = 0;
|
|
324
|
+
const penaltyBreakdown = { repetition: 0, sequence: 0, shortLength: 0, year: 0, dictionary: 0, predictablePhrase: 0 };
|
|
325
|
+
const riskFlags = [];
|
|
326
|
+
const tips = [];
|
|
327
|
+
const uniqueChars = new Set(pw.split('')).size;
|
|
328
|
+
if (pw.length > 6) {
|
|
329
|
+
const ratio = uniqueChars / pw.length;
|
|
330
|
+
if (ratio <= 0.6) {
|
|
331
|
+
const p = Math.round((1 - ratio) * 80);
|
|
332
|
+
penalty += p;
|
|
333
|
+
penaltyBreakdown.repetition += p;
|
|
334
|
+
riskFlags.push('repetition');
|
|
335
|
+
tips.push(this.t('tips.repetition', locale));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (/(123|abc|qwe|asd|zxc|321|cba|ewq)/i.test(pw)) {
|
|
339
|
+
penalty += 20;
|
|
340
|
+
penaltyBreakdown.sequence += 20;
|
|
341
|
+
riskFlags.push('sequence');
|
|
342
|
+
tips.push(this.t('tips.sequence', locale));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Short passwords are easier to brute-force even with decoration/leetspeak.
|
|
346
|
+
if (pw.length < 12) {
|
|
347
|
+
penalty += 24; // 1.5x from prior 16
|
|
348
|
+
penaltyBreakdown.shortLength += 24;
|
|
349
|
+
riskFlags.push('short_length');
|
|
350
|
+
tips.push(this.t('tips.short', locale));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Year-like patterns are highly predictable (e.g. name + 2026 + !)
|
|
354
|
+
if (/(?:19\d{2}|20\d{2})/.test(pw)) {
|
|
355
|
+
penalty += 36; // doubled year penalty
|
|
356
|
+
penaltyBreakdown.year += 36;
|
|
357
|
+
riskFlags.push('year_pattern');
|
|
358
|
+
tips.push(this.t('tips.year', locale));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const langs = options.languages || this.activeLanguages;
|
|
362
|
+
const matchedParts = this.findDictionaryMatches(pw, langs);
|
|
363
|
+
const dictionaryMatches = matchedParts.length;
|
|
364
|
+
const passphrase = this.assessPassphrase(pw, matchedParts);
|
|
365
|
+
|
|
366
|
+
const letterRuns = this.getLetterRuns(pw);
|
|
367
|
+
const letterCount = letterRuns.reduce((sum, run) => sum + run.text.length, 0);
|
|
368
|
+
const matchedLetterCount = matchedParts.reduce((sum, m) => sum + m.len, 0);
|
|
369
|
+
const dictionaryCoverage = letterCount > 0 ? matchedLetterCount / letterCount : 0;
|
|
370
|
+
|
|
371
|
+
if (dictionaryMatches === 1) penaltyBreakdown.dictionary += 40;
|
|
372
|
+
else if (dictionaryMatches === 2) penaltyBreakdown.dictionary += 35;
|
|
373
|
+
else if (dictionaryMatches > 2) penaltyBreakdown.dictionary += 15;
|
|
374
|
+
|
|
375
|
+
if (penaltyBreakdown.dictionary > 0) {
|
|
376
|
+
if (passphrase.qualifies) {
|
|
377
|
+
penaltyBreakdown.dictionary = Math.max(10, penaltyBreakdown.dictionary - 10);
|
|
378
|
+
}
|
|
379
|
+
penalty += penaltyBreakdown.dictionary;
|
|
380
|
+
riskFlags.push('dictionary_pattern');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// If phrase is almost entirely common dictionary words, keep score realistic.
|
|
384
|
+
const wordOnlyCompositionRe = this.createUnicodeRegex("^[\\p{L}\\-_'\\s]+$", 'u') || /^[A-Za-zÅÄÖåäö\-_'\s]+$/;
|
|
385
|
+
const wordOnlyComposition = wordOnlyCompositionRe.test(pw);
|
|
386
|
+
const isPredictablePhrase = passphrase.qualifies && (dictionaryCoverage >= 0.9 || (wordOnlyComposition && passphrase.words >= 3));
|
|
387
|
+
if (isPredictablePhrase) {
|
|
388
|
+
penaltyBreakdown.predictablePhrase = 35;
|
|
389
|
+
penalty += penaltyBreakdown.predictablePhrase;
|
|
390
|
+
riskFlags.push('predictable_phrase');
|
|
391
|
+
tips.push(this.t('tips.phrase', locale));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const bonusBreakdown = { passphrase: 0 };
|
|
395
|
+
if (passphrase.qualifies && !riskFlags.includes('sequence') && !riskFlags.includes('year_pattern')) {
|
|
396
|
+
bonusBreakdown.passphrase = isPredictablePhrase
|
|
397
|
+
? Math.max(6, Math.round(passphrase.bonus * 0.35))
|
|
398
|
+
: passphrase.bonus;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const rawScore = Math.max(0, Math.min(100, Math.round(score - penalty + bonusBreakdown.passphrase)));
|
|
402
|
+
|
|
403
|
+
// Base label by score band
|
|
404
|
+
let labelKey = 'weak';
|
|
405
|
+
if (rawScore >= 85) labelKey = 'strong';
|
|
406
|
+
else if (rawScore >= 70) labelKey = 'good';
|
|
407
|
+
else if (rawScore >= 40) labelKey = 'moderate';
|
|
408
|
+
|
|
409
|
+
// Conservative cap: predictable structure cannot be labeled too high
|
|
410
|
+
const hasCriticalRisk = riskFlags.includes('year_pattern') || riskFlags.includes('dictionary_pattern') || riskFlags.includes('sequence');
|
|
411
|
+
if (hasCriticalRisk && (labelKey === 'strong' || labelKey === 'good')) {
|
|
412
|
+
labelKey = passphrase.qualifies && !riskFlags.includes('year_pattern') && !riskFlags.includes('sequence') ? 'good' : 'moderate';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (rawScore === 0 && penalty >= 50) labelKey = 'dangerous';
|
|
416
|
+
|
|
417
|
+
const label = this.t(`labels.${labelKey}`, locale);
|
|
418
|
+
|
|
419
|
+
// No global score capping. Only apply a soft ceiling for highly predictable, word-only passphrases.
|
|
420
|
+
let adjustedScore = rawScore;
|
|
421
|
+
if (isPredictablePhrase) {
|
|
422
|
+
const softCeiling = Math.min(88, 70 + Math.max(0, (pw.length - 12) * 0.8));
|
|
423
|
+
adjustedScore = Math.min(adjustedScore, Math.round(softCeiling));
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
score: adjustedScore,
|
|
427
|
+
rawScore,
|
|
428
|
+
label,
|
|
429
|
+
labelKey,
|
|
430
|
+
confidence: hasCriticalRisk ? 'high' : (matchedParts.length > 0 ? 'medium' : 'low'),
|
|
431
|
+
tips,
|
|
432
|
+
matches: dictionaryMatches,
|
|
433
|
+
matchedParts,
|
|
434
|
+
strategy: passphrase.strategy,
|
|
435
|
+
dictionaryWordCount: passphrase.words,
|
|
436
|
+
riskFlags: [...new Set(riskFlags)],
|
|
437
|
+
scoreBreakdown: {
|
|
438
|
+
baseline: Math.round(baselineScore),
|
|
439
|
+
penalties: penaltyBreakdown,
|
|
440
|
+
bonuses: bonusBreakdown,
|
|
441
|
+
totalPenalty: penalty,
|
|
442
|
+
rawFinal: rawScore,
|
|
443
|
+
capPenalty: Math.max(0, rawScore - adjustedScore),
|
|
444
|
+
final: adjustedScore
|
|
445
|
+
},
|
|
446
|
+
languages: langs
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|