use-password-policy 3.0.0 → 3.1.0
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 +15 -0
- package/README.md +40 -18
- package/dist/core.d.mts +112 -28
- package/dist/core.d.ts +112 -28
- package/dist/core.js +176 -15
- package/dist/core.mjs +169 -14
- package/dist/index.d.mts +174 -86
- package/dist/index.d.ts +174 -86
- package/dist/index.js +52 -456
- package/dist/index.mjs +49 -444
- package/dist/styles.css +1 -0
- package/package.json +1 -1
package/dist/core.js
CHANGED
|
@@ -23,14 +23,20 @@ __export(core_exports, {
|
|
|
23
23
|
COMMON_PASSWORDS: () => COMMON_PASSWORDS,
|
|
24
24
|
DEFAULT_MESSAGES: () => DEFAULT_MESSAGES,
|
|
25
25
|
DEFAULT_OPTIONS: () => DEFAULT_OPTIONS,
|
|
26
|
+
applyBreachResult: () => applyBreachResult,
|
|
26
27
|
checkPwnedPassword: () => checkPwnedPassword,
|
|
27
28
|
fromZxcvbn: () => fromZxcvbn,
|
|
28
29
|
isCommonPassword: () => isCommonPassword,
|
|
30
|
+
isPredictablePattern: () => isPredictablePattern,
|
|
31
|
+
passwordLength: () => passwordLength,
|
|
29
32
|
passwordValidator: () => passwordValidator,
|
|
33
|
+
passwordValidatorAsync: () => passwordValidatorAsync,
|
|
30
34
|
presets: () => presets,
|
|
31
35
|
resolveOptions: () => resolveOptions,
|
|
32
36
|
validatePassword: () => validatePassword,
|
|
33
|
-
|
|
37
|
+
validatePasswordAsync: () => validatePasswordAsync,
|
|
38
|
+
zodPasswordRule: () => zodPasswordRule,
|
|
39
|
+
zodPasswordRuleAsync: () => zodPasswordRuleAsync
|
|
34
40
|
});
|
|
35
41
|
module.exports = __toCommonJS(core_exports);
|
|
36
42
|
|
|
@@ -213,6 +219,25 @@ var COMMON_PASSWORDS = [
|
|
|
213
219
|
|
|
214
220
|
// src/pwned.ts
|
|
215
221
|
var HIBP_RANGE = "https://api.pwnedpasswords.com/range/";
|
|
222
|
+
var CACHE_LIMIT = 100;
|
|
223
|
+
var responseCache = /* @__PURE__ */ new WeakMap();
|
|
224
|
+
var inflight = /* @__PURE__ */ new WeakMap();
|
|
225
|
+
function inflightFor(fetchFn) {
|
|
226
|
+
let map = inflight.get(fetchFn);
|
|
227
|
+
if (!map) {
|
|
228
|
+
map = /* @__PURE__ */ new Map();
|
|
229
|
+
inflight.set(fetchFn, map);
|
|
230
|
+
}
|
|
231
|
+
return map;
|
|
232
|
+
}
|
|
233
|
+
function cacheFor(fetchFn) {
|
|
234
|
+
let cache = responseCache.get(fetchFn);
|
|
235
|
+
if (!cache) {
|
|
236
|
+
cache = /* @__PURE__ */ new Map();
|
|
237
|
+
responseCache.set(fetchFn, cache);
|
|
238
|
+
}
|
|
239
|
+
return cache;
|
|
240
|
+
}
|
|
216
241
|
async function sha1Hex(input) {
|
|
217
242
|
var _a;
|
|
218
243
|
const subtle = (_a = globalThis.crypto) == null ? void 0 : _a.subtle;
|
|
@@ -225,19 +250,33 @@ async function sha1Hex(input) {
|
|
|
225
250
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
226
251
|
}
|
|
227
252
|
async function checkPwnedPassword(password, options = {}) {
|
|
228
|
-
var _a, _b;
|
|
253
|
+
var _a, _b, _c;
|
|
229
254
|
if (!password) return 0;
|
|
230
255
|
const doFetch = (_a = options.fetch) != null ? _a : globalThis.fetch;
|
|
231
256
|
if (!doFetch) throw new Error("use-password-policy: no fetch implementation available.");
|
|
232
257
|
const hash = await sha1Hex(password);
|
|
233
258
|
const prefix = hash.slice(0, 5);
|
|
234
259
|
const suffix = hash.slice(5);
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
260
|
+
const url = `${(_b = options.endpoint) != null ? _b : HIBP_RANGE}${prefix}`;
|
|
261
|
+
const cache = cacheFor(doFetch);
|
|
262
|
+
let body = cache.get(url);
|
|
263
|
+
if (body === void 0) {
|
|
264
|
+
const pending = inflightFor(doFetch);
|
|
265
|
+
let request = pending.get(url);
|
|
266
|
+
if (!request) {
|
|
267
|
+
request = (async () => {
|
|
268
|
+
const res = await doFetch(url, { headers: options.padding ? { "Add-Padding": "true" } : void 0 });
|
|
269
|
+
if (!res.ok) throw new Error(`use-password-policy: breach check failed (HTTP ${res.status}).`);
|
|
270
|
+
const text = await res.text();
|
|
271
|
+
cache.set(url, text);
|
|
272
|
+
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
273
|
+
return text;
|
|
274
|
+
})().finally(() => pending.delete(url));
|
|
275
|
+
pending.set(url, request);
|
|
276
|
+
}
|
|
277
|
+
body = await request;
|
|
278
|
+
if ((_c = options.signal) == null ? void 0 : _c.aborted) throw new DOMException("The check was cancelled.", "AbortError");
|
|
279
|
+
}
|
|
241
280
|
for (const line of body.split("\n")) {
|
|
242
281
|
const [lineSuffix, count] = line.trim().split(":");
|
|
243
282
|
if (lineSuffix === suffix) return parseInt(count, 10) || 0;
|
|
@@ -255,6 +294,7 @@ var DEFAULT_OPTIONS = {
|
|
|
255
294
|
specialCharCheck: true,
|
|
256
295
|
commonPasswordCheck: false,
|
|
257
296
|
commonPasswords: COMMON_PASSWORDS,
|
|
297
|
+
patternCheck: false,
|
|
258
298
|
customRules: [],
|
|
259
299
|
messages: {},
|
|
260
300
|
lowercaseRegex: /[a-z]/,
|
|
@@ -273,7 +313,8 @@ var presets = {
|
|
|
273
313
|
},
|
|
274
314
|
/**
|
|
275
315
|
* NIST SP 800-63B-4 (Aug 2025) for passwords used on their own:
|
|
276
|
-
* at least 15 characters, allow up to 64, no composition rules, block common passwords
|
|
316
|
+
* at least 15 characters, allow up to 64, no composition rules, block common passwords
|
|
317
|
+
* and predictable patterns. Add `breachCheck: true` to also check known breaches.
|
|
277
318
|
*/
|
|
278
319
|
nist: {
|
|
279
320
|
minLength: 15,
|
|
@@ -282,7 +323,8 @@ var presets = {
|
|
|
282
323
|
uppercaseCheck: false,
|
|
283
324
|
numberCheck: false,
|
|
284
325
|
specialCharCheck: false,
|
|
285
|
-
commonPasswordCheck: true
|
|
326
|
+
commonPasswordCheck: true,
|
|
327
|
+
patternCheck: true
|
|
286
328
|
},
|
|
287
329
|
/** NIST SP 800-63B-4 when the password is one factor of MFA: at least 8 characters. */
|
|
288
330
|
nistMfa: {
|
|
@@ -292,7 +334,8 @@ var presets = {
|
|
|
292
334
|
uppercaseCheck: false,
|
|
293
335
|
numberCheck: false,
|
|
294
336
|
specialCharCheck: false,
|
|
295
|
-
commonPasswordCheck: true
|
|
337
|
+
commonPasswordCheck: true,
|
|
338
|
+
patternCheck: true
|
|
296
339
|
}
|
|
297
340
|
};
|
|
298
341
|
var DEFAULT_MESSAGES = {
|
|
@@ -303,6 +346,9 @@ var DEFAULT_MESSAGES = {
|
|
|
303
346
|
number: "A number",
|
|
304
347
|
specialChar: "A special character",
|
|
305
348
|
notCommon: "Not a commonly used password",
|
|
349
|
+
noPattern: "No repeats, sequences or keyboard patterns",
|
|
350
|
+
notBreached: "Not found in known data breaches",
|
|
351
|
+
breachUnavailable: "Couldn't check known data breaches. Try again",
|
|
306
352
|
match: "Passwords match",
|
|
307
353
|
strength: "Hard to guess"
|
|
308
354
|
};
|
|
@@ -328,6 +374,22 @@ function getCommonSet(list) {
|
|
|
328
374
|
}
|
|
329
375
|
return set;
|
|
330
376
|
}
|
|
377
|
+
var passwordLength = (password) => Array.from(password).length;
|
|
378
|
+
function isCommonCombo(word, set) {
|
|
379
|
+
const n = word.length;
|
|
380
|
+
if (n < 6) return false;
|
|
381
|
+
const reachable = new Array(n + 1).fill(false);
|
|
382
|
+
reachable[0] = true;
|
|
383
|
+
for (let end = 3; end <= n; end++) {
|
|
384
|
+
for (let start = Math.max(0, end - 20); start <= end - 3; start++) {
|
|
385
|
+
if (reachable[start] && set.has(word.slice(start, end))) {
|
|
386
|
+
reachable[end] = true;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return reachable[n];
|
|
392
|
+
}
|
|
331
393
|
function isCommonPassword(password, list = COMMON_PASSWORDS) {
|
|
332
394
|
if (!password) return false;
|
|
333
395
|
const set = getCommonSet(list);
|
|
@@ -336,7 +398,36 @@ function isCommonPassword(password, list = COMMON_PASSWORDS) {
|
|
|
336
398
|
const trimmed = lower.replace(/[^a-z]+$/, "");
|
|
337
399
|
const base = unleet(trimmed);
|
|
338
400
|
const baseNoLeading = unleet(trimmed.replace(/^[^a-z]+/, ""));
|
|
339
|
-
|
|
401
|
+
if ([lower, noTrailingSymbols, base, baseNoLeading].some((c) => c.length > 0 && set.has(c))) return true;
|
|
402
|
+
const lettersOnly = lower.replace(/[^a-z]/g, "");
|
|
403
|
+
const unleetedLetters = unleet(baseNoLeading).replace(/[^a-z]/g, "");
|
|
404
|
+
return isCommonCombo(lettersOnly, set) || isCommonCombo(unleetedLetters, set);
|
|
405
|
+
}
|
|
406
|
+
var KEYBOARD_ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm", "abcdefghijklmnopqrstuvwxyz"];
|
|
407
|
+
function isStep(a, b) {
|
|
408
|
+
if (a === b) return false;
|
|
409
|
+
for (const row of KEYBOARD_ROWS) {
|
|
410
|
+
const i = row.indexOf(a);
|
|
411
|
+
const j = row.indexOf(b);
|
|
412
|
+
if (i >= 0 && j >= 0 && (Math.abs(i - j) === 1 || Math.abs(i - j) === row.length - 1)) return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
function isPredictablePattern(password) {
|
|
417
|
+
if (!password) return false;
|
|
418
|
+
if (password.trim() === "") return true;
|
|
419
|
+
const chars = Array.from(password.toLowerCase());
|
|
420
|
+
if (new Set(chars).size <= 3) return true;
|
|
421
|
+
const compact = chars.filter((c) => /[a-z0-9]/.test(c) || c.toUpperCase() !== c).join("");
|
|
422
|
+
for (const s of [compact, compact.replace(/\d+$/, "")]) {
|
|
423
|
+
if (s.length >= 6 && /^(.+?)\1+$/.test(s)) return true;
|
|
424
|
+
}
|
|
425
|
+
if (chars.length >= 4) {
|
|
426
|
+
let steps = 0;
|
|
427
|
+
for (let i = 1; i < chars.length; i++) if (isStep(chars[i - 1], chars[i])) steps++;
|
|
428
|
+
if (steps / (chars.length - 1) >= 0.75) return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
340
431
|
}
|
|
341
432
|
var prettify = (name) => name.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()).trim();
|
|
342
433
|
function resolveMessage(rule, options) {
|
|
@@ -348,10 +439,10 @@ function resolveMessage(rule, options) {
|
|
|
348
439
|
function builtInRules(options, estimate) {
|
|
349
440
|
const rules = [];
|
|
350
441
|
if (options.minLength > 0) {
|
|
351
|
-
rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => p
|
|
442
|
+
rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => passwordLength(p) >= o.minLength });
|
|
352
443
|
}
|
|
353
444
|
if (options.maxLength > 0) {
|
|
354
|
-
rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => p
|
|
445
|
+
rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => passwordLength(p) <= o.maxLength });
|
|
355
446
|
}
|
|
356
447
|
if (options.uppercaseCheck) {
|
|
357
448
|
rules.push({ name: "uppercase", optionsKey: "uppercaseCheck", test: (p, o) => o.uppercaseRegex.test(p) });
|
|
@@ -372,6 +463,13 @@ function builtInRules(options, estimate) {
|
|
|
372
463
|
test: (p, o) => p.length > 0 && !isCommonPassword(p, o.commonPasswords)
|
|
373
464
|
});
|
|
374
465
|
}
|
|
466
|
+
if (options.patternCheck) {
|
|
467
|
+
rules.push({
|
|
468
|
+
name: "noPattern",
|
|
469
|
+
optionsKey: "patternCheck",
|
|
470
|
+
test: (p) => p.length > 0 && !isPredictablePattern(p)
|
|
471
|
+
});
|
|
472
|
+
}
|
|
375
473
|
if (options.confirmPassword !== void 0) {
|
|
376
474
|
rules.push({
|
|
377
475
|
name: "match",
|
|
@@ -428,6 +526,49 @@ function validatePassword(password, options = {}) {
|
|
|
428
526
|
...estimate ? { estimate } : {}
|
|
429
527
|
};
|
|
430
528
|
}
|
|
529
|
+
function breachOptions(option) {
|
|
530
|
+
if (!option) return null;
|
|
531
|
+
return option === true ? {} : option;
|
|
532
|
+
}
|
|
533
|
+
function applyBreachResult(result, breach, options = {}) {
|
|
534
|
+
var _a;
|
|
535
|
+
const failOpen = ((_a = breachOptions(options.breachCheck)) != null ? _a : {}).failOpen !== false;
|
|
536
|
+
const resolved = resolveOptions(options);
|
|
537
|
+
const message = resolveMessage({ name: "notBreached", test: () => true }, resolved);
|
|
538
|
+
const pending = breach.status === "idle" || breach.status === "checking";
|
|
539
|
+
const passed = breach.status === "safe" || breach.status === "error" && failOpen;
|
|
540
|
+
const requirement = pending ? { name: "notBreached", passed: false, message, pending: true } : { name: "notBreached", passed, message };
|
|
541
|
+
const errors = [...result.errors];
|
|
542
|
+
if (!pending && !passed) {
|
|
543
|
+
errors.push(
|
|
544
|
+
breach.status === "error" ? resolveMessage({ name: "breachUnavailable", test: () => true }, resolved) : message
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const pwned = breach.status === "pwned";
|
|
548
|
+
return {
|
|
549
|
+
...result,
|
|
550
|
+
isValid: result.isValid && passed,
|
|
551
|
+
policyState: { ...result.policyState, notBreached: passed },
|
|
552
|
+
requirements: [...result.requirements.filter((r) => r.name !== "notBreached"), requirement],
|
|
553
|
+
errors,
|
|
554
|
+
strengthScore: result.strengthScore + (passed ? 1 : 0),
|
|
555
|
+
strengthPercent: pwned ? 0 : result.strengthPercent,
|
|
556
|
+
strengthLabel: pwned ? "Very Weak" : result.strengthLabel,
|
|
557
|
+
breach
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
async function validatePasswordAsync(password, options = {}) {
|
|
561
|
+
const result = validatePassword(password, options);
|
|
562
|
+
const b = breachOptions(options.breachCheck);
|
|
563
|
+
if (!b) return result;
|
|
564
|
+
if (!result.isValid) return applyBreachResult(result, { status: "idle", count: 0 }, options);
|
|
565
|
+
try {
|
|
566
|
+
const count = await checkPwnedPassword(password != null ? password : "", { fetch: b.fetch, endpoint: b.endpoint, padding: b.padding });
|
|
567
|
+
return applyBreachResult(result, { status: count > 0 ? "pwned" : "safe", count }, options);
|
|
568
|
+
} catch {
|
|
569
|
+
return applyBreachResult(result, { status: "error", count: 0 }, options);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
431
572
|
function fromZxcvbn(zxcvbn, userInputs) {
|
|
432
573
|
const run = typeof zxcvbn === "function" ? zxcvbn : (pw, inputs) => zxcvbn.check(pw, inputs);
|
|
433
574
|
return (password) => {
|
|
@@ -446,6 +587,20 @@ function zodPasswordRule(options = {}, { allErrors = true } = {}) {
|
|
|
446
587
|
}
|
|
447
588
|
};
|
|
448
589
|
}
|
|
590
|
+
function zodPasswordRuleAsync(options = {}, { allErrors = true } = {}) {
|
|
591
|
+
return async (value, ctx) => {
|
|
592
|
+
const { errors } = await validatePasswordAsync(value, options);
|
|
593
|
+
for (const message of allErrors ? errors : errors.slice(0, 1)) {
|
|
594
|
+
ctx.addIssue({ code: "custom", message });
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function passwordValidatorAsync(options = {}) {
|
|
599
|
+
return async (value) => {
|
|
600
|
+
const { errors } = await validatePasswordAsync(value != null ? value : "", options);
|
|
601
|
+
return errors.length === 0 ? true : errors[0];
|
|
602
|
+
};
|
|
603
|
+
}
|
|
449
604
|
function passwordValidator(options = {}) {
|
|
450
605
|
return (value) => {
|
|
451
606
|
const { errors } = validatePassword(value != null ? value : "", options);
|
|
@@ -457,12 +612,18 @@ function passwordValidator(options = {}) {
|
|
|
457
612
|
COMMON_PASSWORDS,
|
|
458
613
|
DEFAULT_MESSAGES,
|
|
459
614
|
DEFAULT_OPTIONS,
|
|
615
|
+
applyBreachResult,
|
|
460
616
|
checkPwnedPassword,
|
|
461
617
|
fromZxcvbn,
|
|
462
618
|
isCommonPassword,
|
|
619
|
+
isPredictablePattern,
|
|
620
|
+
passwordLength,
|
|
463
621
|
passwordValidator,
|
|
622
|
+
passwordValidatorAsync,
|
|
464
623
|
presets,
|
|
465
624
|
resolveOptions,
|
|
466
625
|
validatePassword,
|
|
467
|
-
|
|
626
|
+
validatePasswordAsync,
|
|
627
|
+
zodPasswordRule,
|
|
628
|
+
zodPasswordRuleAsync
|
|
468
629
|
});
|
package/dist/core.mjs
CHANGED
|
@@ -177,6 +177,25 @@ var COMMON_PASSWORDS = [
|
|
|
177
177
|
|
|
178
178
|
// src/pwned.ts
|
|
179
179
|
var HIBP_RANGE = "https://api.pwnedpasswords.com/range/";
|
|
180
|
+
var CACHE_LIMIT = 100;
|
|
181
|
+
var responseCache = /* @__PURE__ */ new WeakMap();
|
|
182
|
+
var inflight = /* @__PURE__ */ new WeakMap();
|
|
183
|
+
function inflightFor(fetchFn) {
|
|
184
|
+
let map = inflight.get(fetchFn);
|
|
185
|
+
if (!map) {
|
|
186
|
+
map = /* @__PURE__ */ new Map();
|
|
187
|
+
inflight.set(fetchFn, map);
|
|
188
|
+
}
|
|
189
|
+
return map;
|
|
190
|
+
}
|
|
191
|
+
function cacheFor(fetchFn) {
|
|
192
|
+
let cache = responseCache.get(fetchFn);
|
|
193
|
+
if (!cache) {
|
|
194
|
+
cache = /* @__PURE__ */ new Map();
|
|
195
|
+
responseCache.set(fetchFn, cache);
|
|
196
|
+
}
|
|
197
|
+
return cache;
|
|
198
|
+
}
|
|
180
199
|
async function sha1Hex(input) {
|
|
181
200
|
var _a;
|
|
182
201
|
const subtle = (_a = globalThis.crypto) == null ? void 0 : _a.subtle;
|
|
@@ -189,19 +208,33 @@ async function sha1Hex(input) {
|
|
|
189
208
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
190
209
|
}
|
|
191
210
|
async function checkPwnedPassword(password, options = {}) {
|
|
192
|
-
var _a, _b;
|
|
211
|
+
var _a, _b, _c;
|
|
193
212
|
if (!password) return 0;
|
|
194
213
|
const doFetch = (_a = options.fetch) != null ? _a : globalThis.fetch;
|
|
195
214
|
if (!doFetch) throw new Error("use-password-policy: no fetch implementation available.");
|
|
196
215
|
const hash = await sha1Hex(password);
|
|
197
216
|
const prefix = hash.slice(0, 5);
|
|
198
217
|
const suffix = hash.slice(5);
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
const url = `${(_b = options.endpoint) != null ? _b : HIBP_RANGE}${prefix}`;
|
|
219
|
+
const cache = cacheFor(doFetch);
|
|
220
|
+
let body = cache.get(url);
|
|
221
|
+
if (body === void 0) {
|
|
222
|
+
const pending = inflightFor(doFetch);
|
|
223
|
+
let request = pending.get(url);
|
|
224
|
+
if (!request) {
|
|
225
|
+
request = (async () => {
|
|
226
|
+
const res = await doFetch(url, { headers: options.padding ? { "Add-Padding": "true" } : void 0 });
|
|
227
|
+
if (!res.ok) throw new Error(`use-password-policy: breach check failed (HTTP ${res.status}).`);
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
cache.set(url, text);
|
|
230
|
+
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
231
|
+
return text;
|
|
232
|
+
})().finally(() => pending.delete(url));
|
|
233
|
+
pending.set(url, request);
|
|
234
|
+
}
|
|
235
|
+
body = await request;
|
|
236
|
+
if ((_c = options.signal) == null ? void 0 : _c.aborted) throw new DOMException("The check was cancelled.", "AbortError");
|
|
237
|
+
}
|
|
205
238
|
for (const line of body.split("\n")) {
|
|
206
239
|
const [lineSuffix, count] = line.trim().split(":");
|
|
207
240
|
if (lineSuffix === suffix) return parseInt(count, 10) || 0;
|
|
@@ -219,6 +252,7 @@ var DEFAULT_OPTIONS = {
|
|
|
219
252
|
specialCharCheck: true,
|
|
220
253
|
commonPasswordCheck: false,
|
|
221
254
|
commonPasswords: COMMON_PASSWORDS,
|
|
255
|
+
patternCheck: false,
|
|
222
256
|
customRules: [],
|
|
223
257
|
messages: {},
|
|
224
258
|
lowercaseRegex: /[a-z]/,
|
|
@@ -237,7 +271,8 @@ var presets = {
|
|
|
237
271
|
},
|
|
238
272
|
/**
|
|
239
273
|
* NIST SP 800-63B-4 (Aug 2025) for passwords used on their own:
|
|
240
|
-
* at least 15 characters, allow up to 64, no composition rules, block common passwords
|
|
274
|
+
* at least 15 characters, allow up to 64, no composition rules, block common passwords
|
|
275
|
+
* and predictable patterns. Add `breachCheck: true` to also check known breaches.
|
|
241
276
|
*/
|
|
242
277
|
nist: {
|
|
243
278
|
minLength: 15,
|
|
@@ -246,7 +281,8 @@ var presets = {
|
|
|
246
281
|
uppercaseCheck: false,
|
|
247
282
|
numberCheck: false,
|
|
248
283
|
specialCharCheck: false,
|
|
249
|
-
commonPasswordCheck: true
|
|
284
|
+
commonPasswordCheck: true,
|
|
285
|
+
patternCheck: true
|
|
250
286
|
},
|
|
251
287
|
/** NIST SP 800-63B-4 when the password is one factor of MFA: at least 8 characters. */
|
|
252
288
|
nistMfa: {
|
|
@@ -256,7 +292,8 @@ var presets = {
|
|
|
256
292
|
uppercaseCheck: false,
|
|
257
293
|
numberCheck: false,
|
|
258
294
|
specialCharCheck: false,
|
|
259
|
-
commonPasswordCheck: true
|
|
295
|
+
commonPasswordCheck: true,
|
|
296
|
+
patternCheck: true
|
|
260
297
|
}
|
|
261
298
|
};
|
|
262
299
|
var DEFAULT_MESSAGES = {
|
|
@@ -267,6 +304,9 @@ var DEFAULT_MESSAGES = {
|
|
|
267
304
|
number: "A number",
|
|
268
305
|
specialChar: "A special character",
|
|
269
306
|
notCommon: "Not a commonly used password",
|
|
307
|
+
noPattern: "No repeats, sequences or keyboard patterns",
|
|
308
|
+
notBreached: "Not found in known data breaches",
|
|
309
|
+
breachUnavailable: "Couldn't check known data breaches. Try again",
|
|
270
310
|
match: "Passwords match",
|
|
271
311
|
strength: "Hard to guess"
|
|
272
312
|
};
|
|
@@ -292,6 +332,22 @@ function getCommonSet(list) {
|
|
|
292
332
|
}
|
|
293
333
|
return set;
|
|
294
334
|
}
|
|
335
|
+
var passwordLength = (password) => Array.from(password).length;
|
|
336
|
+
function isCommonCombo(word, set) {
|
|
337
|
+
const n = word.length;
|
|
338
|
+
if (n < 6) return false;
|
|
339
|
+
const reachable = new Array(n + 1).fill(false);
|
|
340
|
+
reachable[0] = true;
|
|
341
|
+
for (let end = 3; end <= n; end++) {
|
|
342
|
+
for (let start = Math.max(0, end - 20); start <= end - 3; start++) {
|
|
343
|
+
if (reachable[start] && set.has(word.slice(start, end))) {
|
|
344
|
+
reachable[end] = true;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return reachable[n];
|
|
350
|
+
}
|
|
295
351
|
function isCommonPassword(password, list = COMMON_PASSWORDS) {
|
|
296
352
|
if (!password) return false;
|
|
297
353
|
const set = getCommonSet(list);
|
|
@@ -300,7 +356,36 @@ function isCommonPassword(password, list = COMMON_PASSWORDS) {
|
|
|
300
356
|
const trimmed = lower.replace(/[^a-z]+$/, "");
|
|
301
357
|
const base = unleet(trimmed);
|
|
302
358
|
const baseNoLeading = unleet(trimmed.replace(/^[^a-z]+/, ""));
|
|
303
|
-
|
|
359
|
+
if ([lower, noTrailingSymbols, base, baseNoLeading].some((c) => c.length > 0 && set.has(c))) return true;
|
|
360
|
+
const lettersOnly = lower.replace(/[^a-z]/g, "");
|
|
361
|
+
const unleetedLetters = unleet(baseNoLeading).replace(/[^a-z]/g, "");
|
|
362
|
+
return isCommonCombo(lettersOnly, set) || isCommonCombo(unleetedLetters, set);
|
|
363
|
+
}
|
|
364
|
+
var KEYBOARD_ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm", "abcdefghijklmnopqrstuvwxyz"];
|
|
365
|
+
function isStep(a, b) {
|
|
366
|
+
if (a === b) return false;
|
|
367
|
+
for (const row of KEYBOARD_ROWS) {
|
|
368
|
+
const i = row.indexOf(a);
|
|
369
|
+
const j = row.indexOf(b);
|
|
370
|
+
if (i >= 0 && j >= 0 && (Math.abs(i - j) === 1 || Math.abs(i - j) === row.length - 1)) return true;
|
|
371
|
+
}
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
function isPredictablePattern(password) {
|
|
375
|
+
if (!password) return false;
|
|
376
|
+
if (password.trim() === "") return true;
|
|
377
|
+
const chars = Array.from(password.toLowerCase());
|
|
378
|
+
if (new Set(chars).size <= 3) return true;
|
|
379
|
+
const compact = chars.filter((c) => /[a-z0-9]/.test(c) || c.toUpperCase() !== c).join("");
|
|
380
|
+
for (const s of [compact, compact.replace(/\d+$/, "")]) {
|
|
381
|
+
if (s.length >= 6 && /^(.+?)\1+$/.test(s)) return true;
|
|
382
|
+
}
|
|
383
|
+
if (chars.length >= 4) {
|
|
384
|
+
let steps = 0;
|
|
385
|
+
for (let i = 1; i < chars.length; i++) if (isStep(chars[i - 1], chars[i])) steps++;
|
|
386
|
+
if (steps / (chars.length - 1) >= 0.75) return true;
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
304
389
|
}
|
|
305
390
|
var prettify = (name) => name.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()).trim();
|
|
306
391
|
function resolveMessage(rule, options) {
|
|
@@ -312,10 +397,10 @@ function resolveMessage(rule, options) {
|
|
|
312
397
|
function builtInRules(options, estimate) {
|
|
313
398
|
const rules = [];
|
|
314
399
|
if (options.minLength > 0) {
|
|
315
|
-
rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => p
|
|
400
|
+
rules.push({ name: "minLength", optionsKey: "minLength", test: (p, o) => passwordLength(p) >= o.minLength });
|
|
316
401
|
}
|
|
317
402
|
if (options.maxLength > 0) {
|
|
318
|
-
rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => p
|
|
403
|
+
rules.push({ name: "maxLength", optionsKey: "maxLength", test: (p, o) => passwordLength(p) <= o.maxLength });
|
|
319
404
|
}
|
|
320
405
|
if (options.uppercaseCheck) {
|
|
321
406
|
rules.push({ name: "uppercase", optionsKey: "uppercaseCheck", test: (p, o) => o.uppercaseRegex.test(p) });
|
|
@@ -336,6 +421,13 @@ function builtInRules(options, estimate) {
|
|
|
336
421
|
test: (p, o) => p.length > 0 && !isCommonPassword(p, o.commonPasswords)
|
|
337
422
|
});
|
|
338
423
|
}
|
|
424
|
+
if (options.patternCheck) {
|
|
425
|
+
rules.push({
|
|
426
|
+
name: "noPattern",
|
|
427
|
+
optionsKey: "patternCheck",
|
|
428
|
+
test: (p) => p.length > 0 && !isPredictablePattern(p)
|
|
429
|
+
});
|
|
430
|
+
}
|
|
339
431
|
if (options.confirmPassword !== void 0) {
|
|
340
432
|
rules.push({
|
|
341
433
|
name: "match",
|
|
@@ -392,6 +484,49 @@ function validatePassword(password, options = {}) {
|
|
|
392
484
|
...estimate ? { estimate } : {}
|
|
393
485
|
};
|
|
394
486
|
}
|
|
487
|
+
function breachOptions(option) {
|
|
488
|
+
if (!option) return null;
|
|
489
|
+
return option === true ? {} : option;
|
|
490
|
+
}
|
|
491
|
+
function applyBreachResult(result, breach, options = {}) {
|
|
492
|
+
var _a;
|
|
493
|
+
const failOpen = ((_a = breachOptions(options.breachCheck)) != null ? _a : {}).failOpen !== false;
|
|
494
|
+
const resolved = resolveOptions(options);
|
|
495
|
+
const message = resolveMessage({ name: "notBreached", test: () => true }, resolved);
|
|
496
|
+
const pending = breach.status === "idle" || breach.status === "checking";
|
|
497
|
+
const passed = breach.status === "safe" || breach.status === "error" && failOpen;
|
|
498
|
+
const requirement = pending ? { name: "notBreached", passed: false, message, pending: true } : { name: "notBreached", passed, message };
|
|
499
|
+
const errors = [...result.errors];
|
|
500
|
+
if (!pending && !passed) {
|
|
501
|
+
errors.push(
|
|
502
|
+
breach.status === "error" ? resolveMessage({ name: "breachUnavailable", test: () => true }, resolved) : message
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const pwned = breach.status === "pwned";
|
|
506
|
+
return {
|
|
507
|
+
...result,
|
|
508
|
+
isValid: result.isValid && passed,
|
|
509
|
+
policyState: { ...result.policyState, notBreached: passed },
|
|
510
|
+
requirements: [...result.requirements.filter((r) => r.name !== "notBreached"), requirement],
|
|
511
|
+
errors,
|
|
512
|
+
strengthScore: result.strengthScore + (passed ? 1 : 0),
|
|
513
|
+
strengthPercent: pwned ? 0 : result.strengthPercent,
|
|
514
|
+
strengthLabel: pwned ? "Very Weak" : result.strengthLabel,
|
|
515
|
+
breach
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async function validatePasswordAsync(password, options = {}) {
|
|
519
|
+
const result = validatePassword(password, options);
|
|
520
|
+
const b = breachOptions(options.breachCheck);
|
|
521
|
+
if (!b) return result;
|
|
522
|
+
if (!result.isValid) return applyBreachResult(result, { status: "idle", count: 0 }, options);
|
|
523
|
+
try {
|
|
524
|
+
const count = await checkPwnedPassword(password != null ? password : "", { fetch: b.fetch, endpoint: b.endpoint, padding: b.padding });
|
|
525
|
+
return applyBreachResult(result, { status: count > 0 ? "pwned" : "safe", count }, options);
|
|
526
|
+
} catch {
|
|
527
|
+
return applyBreachResult(result, { status: "error", count: 0 }, options);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
395
530
|
function fromZxcvbn(zxcvbn, userInputs) {
|
|
396
531
|
const run = typeof zxcvbn === "function" ? zxcvbn : (pw, inputs) => zxcvbn.check(pw, inputs);
|
|
397
532
|
return (password) => {
|
|
@@ -410,6 +545,20 @@ function zodPasswordRule(options = {}, { allErrors = true } = {}) {
|
|
|
410
545
|
}
|
|
411
546
|
};
|
|
412
547
|
}
|
|
548
|
+
function zodPasswordRuleAsync(options = {}, { allErrors = true } = {}) {
|
|
549
|
+
return async (value, ctx) => {
|
|
550
|
+
const { errors } = await validatePasswordAsync(value, options);
|
|
551
|
+
for (const message of allErrors ? errors : errors.slice(0, 1)) {
|
|
552
|
+
ctx.addIssue({ code: "custom", message });
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function passwordValidatorAsync(options = {}) {
|
|
557
|
+
return async (value) => {
|
|
558
|
+
const { errors } = await validatePasswordAsync(value != null ? value : "", options);
|
|
559
|
+
return errors.length === 0 ? true : errors[0];
|
|
560
|
+
};
|
|
561
|
+
}
|
|
413
562
|
function passwordValidator(options = {}) {
|
|
414
563
|
return (value) => {
|
|
415
564
|
const { errors } = validatePassword(value != null ? value : "", options);
|
|
@@ -420,12 +569,18 @@ export {
|
|
|
420
569
|
COMMON_PASSWORDS,
|
|
421
570
|
DEFAULT_MESSAGES,
|
|
422
571
|
DEFAULT_OPTIONS,
|
|
572
|
+
applyBreachResult,
|
|
423
573
|
checkPwnedPassword,
|
|
424
574
|
fromZxcvbn,
|
|
425
575
|
isCommonPassword,
|
|
576
|
+
isPredictablePattern,
|
|
577
|
+
passwordLength,
|
|
426
578
|
passwordValidator,
|
|
579
|
+
passwordValidatorAsync,
|
|
427
580
|
presets,
|
|
428
581
|
resolveOptions,
|
|
429
582
|
validatePassword,
|
|
430
|
-
|
|
583
|
+
validatePasswordAsync,
|
|
584
|
+
zodPasswordRule,
|
|
585
|
+
zodPasswordRuleAsync
|
|
431
586
|
};
|