@enigmax/primitives 0.6.0 → 0.7.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/dist/{chunk-WCB7V7XO.js → chunk-42Y7OOJM.js} +183 -3
- package/dist/index.d.ts +86 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +115 -4
- package/dist/react/index.js +251 -3
- package/package.json +9 -6
- package/recipes/input/styles.css +54 -0
- package/recipes/input/tailwind.tsx +84 -0
- package/registry.json +49 -19
- package/src/core/input.ts +26 -3
- package/src/core/password.ts +252 -0
- package/src/index.ts +10 -0
- package/src/react/index.ts +20 -0
- package/src/react/input.tsx +429 -0
- package/recipes/input.css +0 -32
- package/recipes/input.css.tsx +0 -28
- package/recipes/input.tailwind.tsx +0 -44
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a password, and judging one.
|
|
3
|
+
*
|
|
4
|
+
* Both are opt-in. A sign-in form wants neither: offering to generate a password where one
|
|
5
|
+
* already exists is noise, and scoring one the visitor cannot change is worse. They belong
|
|
6
|
+
* on a registration form and a change-password form, which is where an agent should switch
|
|
7
|
+
* them on.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Character classes a generated password can draw from. */
|
|
11
|
+
export interface PasswordAlphabet {
|
|
12
|
+
lowercase?: boolean;
|
|
13
|
+
uppercase?: boolean;
|
|
14
|
+
digits?: boolean;
|
|
15
|
+
symbols?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GeneratePasswordOptions extends PasswordAlphabet {
|
|
19
|
+
/** Default 20. Long beats clever: length is the only term that scales. */
|
|
20
|
+
length?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Drop the characters that are read wrong off a screen or off paper - I l 1 O 0.
|
|
23
|
+
* Worth it when the password will be typed by hand, not worth the entropy otherwise.
|
|
24
|
+
*/
|
|
25
|
+
excludeAmbiguous?: boolean;
|
|
26
|
+
/** Characters to remove from every class, e.g. ones your backend rejects. */
|
|
27
|
+
exclude?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Guarantee at least one character from every class asked for. Most password policies
|
|
30
|
+
* demand it; it costs a little entropy, because it removes every password that happens
|
|
31
|
+
* to lack one.
|
|
32
|
+
*/
|
|
33
|
+
requireEachClass?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
|
|
37
|
+
const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
38
|
+
const DIGITS = "0123456789";
|
|
39
|
+
/**
|
|
40
|
+
* No quotes, backslash, backtick or space: those are the characters that get mangled on the
|
|
41
|
+
* way through a shell, a CSV, a JSON blob written by hand, or a copy out of a terminal.
|
|
42
|
+
*/
|
|
43
|
+
const SYMBOLS = "!@#$%^&*()-_=+[]{};:,.?";
|
|
44
|
+
const AMBIGUOUS = "Il1O0";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Uniform in [0, bound), by rejection.
|
|
48
|
+
*
|
|
49
|
+
* `value % bound` is the version everyone writes and it is biased: 2^32 is not a multiple
|
|
50
|
+
* of most bounds, so the first few characters of the alphabet come up slightly more often.
|
|
51
|
+
* On a password that is a real, if small, loss of entropy, and it costs one comparison to
|
|
52
|
+
* avoid.
|
|
53
|
+
*/
|
|
54
|
+
function randomIndex(bound: number): number {
|
|
55
|
+
const random = globalThis.crypto?.getRandomValues?.bind(globalThis.crypto);
|
|
56
|
+
if (!random) {
|
|
57
|
+
// Never Math.random. A generator that silently produces predictable passwords is
|
|
58
|
+
// worse than one that refuses: nothing downstream can tell the difference.
|
|
59
|
+
throw new Error("Generating a password needs crypto.getRandomValues, which browsers only expose over HTTPS (or on localhost).");
|
|
60
|
+
}
|
|
61
|
+
const limit = Math.floor(2 ** 32 / bound) * bound;
|
|
62
|
+
const buffer = new Uint32Array(1);
|
|
63
|
+
let value: number;
|
|
64
|
+
do {
|
|
65
|
+
random(buffer);
|
|
66
|
+
value = buffer[0];
|
|
67
|
+
} while (value >= limit);
|
|
68
|
+
return value % bound;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pick(alphabet: string): string {
|
|
72
|
+
return alphabet[randomIndex(alphabet.length)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function classes(options: GeneratePasswordOptions): string[] {
|
|
76
|
+
const { lowercase = true, uppercase = true, digits = true, symbols = true, excludeAmbiguous = false, exclude = "" } = options;
|
|
77
|
+
const banned = new Set([...(excludeAmbiguous ? AMBIGUOUS : ""), ...exclude]);
|
|
78
|
+
const clean = (source: string): string => [...source].filter((character) => !banned.has(character)).join("");
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
lowercase ? clean(LOWERCASE) : "",
|
|
82
|
+
uppercase ? clean(UPPERCASE) : "",
|
|
83
|
+
digits ? clean(DIGITS) : "",
|
|
84
|
+
symbols ? clean(SYMBOLS) : ""
|
|
85
|
+
].filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fisher-Yates, with the same unbiased source. A biased shuffle undoes a fair draw. */
|
|
89
|
+
function shuffle(characters: string[]): string[] {
|
|
90
|
+
for (let index = characters.length - 1; index > 0; index--) {
|
|
91
|
+
const swap = randomIndex(index + 1);
|
|
92
|
+
[characters[index], characters[swap]] = [characters[swap], characters[index]];
|
|
93
|
+
}
|
|
94
|
+
return characters;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A random password from the classes asked for.
|
|
99
|
+
*
|
|
100
|
+
* @throws when the runtime has no CSPRNG, or when the options ask for something impossible
|
|
101
|
+
* (every class excluded, or a length too short to hold one of each).
|
|
102
|
+
*/
|
|
103
|
+
export function generatePassword(options: GeneratePasswordOptions = {}): string {
|
|
104
|
+
const { length = 20, requireEachClass = true } = options;
|
|
105
|
+
const pools = classes(options);
|
|
106
|
+
if (!pools.length) throw new Error("generatePassword: every character class was excluded.");
|
|
107
|
+
if (length < 1) throw new Error("generatePassword: length must be at least 1.");
|
|
108
|
+
if (requireEachClass && length < pools.length) {
|
|
109
|
+
throw new Error(`generatePassword: length ${length} cannot hold one character from each of the ${pools.length} classes requested.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const everything = pools.join("");
|
|
113
|
+
// One from each class first, the rest uniform, then shuffled - so the guarantee does
|
|
114
|
+
// not put the digit in a predictable place.
|
|
115
|
+
const required = requireEachClass ? pools.map(pick) : [];
|
|
116
|
+
const rest = Array.from({ length: length - required.length }, () => pick(everything));
|
|
117
|
+
return shuffle([...required, ...rest]).join("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type PasswordScore = 0 | 1 | 2 | 3 | 4;
|
|
121
|
+
|
|
122
|
+
export interface PasswordStrengthReport {
|
|
123
|
+
/** 0 worst, 4 best. What the bars under the field render. */
|
|
124
|
+
score: PasswordScore;
|
|
125
|
+
/** Estimated bits of entropy after the penalties below. */
|
|
126
|
+
bits: number;
|
|
127
|
+
/**
|
|
128
|
+
* Why it scored what it scored, worst first. Show the first one; showing all of them
|
|
129
|
+
* turns a hint into a lecture.
|
|
130
|
+
*/
|
|
131
|
+
warnings: string[];
|
|
132
|
+
/** Empty field. Render nothing rather than a zero score, which reads as a failure. */
|
|
133
|
+
empty: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface EstimateOptions {
|
|
137
|
+
/**
|
|
138
|
+
* Values the visitor has already typed elsewhere - email, name, company. A password
|
|
139
|
+
* containing one of them is guessable by anyone who has the sign-up form in front of
|
|
140
|
+
* them, and no character-class rule catches it.
|
|
141
|
+
*/
|
|
142
|
+
userInputs?: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The forty or so passwords that turn up at the top of every breach corpus. Not a
|
|
147
|
+
* dictionary: it is here to catch `password1`, not to be exhaustive. For real coverage
|
|
148
|
+
* either replace the estimator with zxcvbn, or check the breach corpus - which is what
|
|
149
|
+
* `checkPasswordBreach` in @enigmax/utils is for.
|
|
150
|
+
*/
|
|
151
|
+
const COMMON = new Set([
|
|
152
|
+
"password", "passwd", "123456", "12345678", "123456789", "1234567890", "qwerty", "qwertyuiop",
|
|
153
|
+
"abc123", "111111", "123123", "admin", "letmein", "welcome", "monkey", "dragon", "sunshine",
|
|
154
|
+
"iloveyou", "princess", "football", "baseball", "master", "shadow", "superman", "batman",
|
|
155
|
+
"trustno1", "hello", "freedom", "whatever", "starwars", "changeme", "secret", "login",
|
|
156
|
+
"root", "toor", "test", "guest", "azerty", "1q2w3e4r", "zaq12wsx"
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const SEQUENCES = ["abcdefghijklmnopqrstuvwxyz", "0123456789", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
|
|
160
|
+
|
|
161
|
+
/** The pool an attacker would have to search, from the classes actually used. */
|
|
162
|
+
function poolSize(password: string): number {
|
|
163
|
+
let size = 0;
|
|
164
|
+
if (/[a-z]/.test(password)) size += 26;
|
|
165
|
+
if (/[A-Z]/.test(password)) size += 26;
|
|
166
|
+
if (/\d/.test(password)) size += 10;
|
|
167
|
+
if (/[^\w\s]|_/.test(password)) size += SYMBOLS.length;
|
|
168
|
+
if (/\s/.test(password)) size += 1;
|
|
169
|
+
return size || 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Longest run of the same character, and longest run along a keyboard or alphabet line. */
|
|
173
|
+
function longestRun(password: string): number {
|
|
174
|
+
let longest = 1, run = 1;
|
|
175
|
+
for (let index = 1; index < password.length; index++) {
|
|
176
|
+
run = password[index] === password[index - 1] ? run + 1 : 1;
|
|
177
|
+
longest = Math.max(longest, run);
|
|
178
|
+
}
|
|
179
|
+
return longest;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function longestSequence(password: string): number {
|
|
183
|
+
const lower = password.toLowerCase();
|
|
184
|
+
let longest = 0;
|
|
185
|
+
for (const line of SEQUENCES) {
|
|
186
|
+
const reversed = [...line].reverse().join("");
|
|
187
|
+
for (const source of [line, reversed]) {
|
|
188
|
+
for (let start = 0; start < source.length; start++) {
|
|
189
|
+
for (let end = source.length; end > start + longest; end--) {
|
|
190
|
+
if (lower.includes(source.slice(start, end))) {
|
|
191
|
+
longest = Math.max(longest, end - start);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return longest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Strip the decoration people add to satisfy a policy: Password1! is password. */
|
|
202
|
+
function core(password: string): string {
|
|
203
|
+
return password.toLowerCase().replace(/^[^a-z]+/, "").replace(/[^a-z]+$/, "").replace(/[0!@$]/g, (character) => ({ "0": "o", "!": "i", "@": "a", "$": "s" })[character] ?? character);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Score a password.
|
|
208
|
+
*
|
|
209
|
+
* The bits are an estimate and the bands are a convention, not a measurement - they exist
|
|
210
|
+
* to move a bar, not to certify anything. Swap this out for zxcvbn where the number has to
|
|
211
|
+
* mean something, and check the breach corpus for the cases no estimator can see.
|
|
212
|
+
*/
|
|
213
|
+
export function estimatePasswordStrength(password: string, options: EstimateOptions = {}): PasswordStrengthReport {
|
|
214
|
+
if (!password) return { score: 0, bits: 0, warnings: [], empty: true };
|
|
215
|
+
|
|
216
|
+
const warnings: string[] = [];
|
|
217
|
+
let bits = password.length * Math.log2(poolSize(password));
|
|
218
|
+
|
|
219
|
+
const stripped = core(password);
|
|
220
|
+
if (COMMON.has(password.toLowerCase()) || COMMON.has(stripped)) {
|
|
221
|
+
// A password on every list has no entropy at all, whatever its shape.
|
|
222
|
+
bits = Math.min(bits, 8);
|
|
223
|
+
warnings.push("This is one of the most common passwords there is.");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const input of options.userInputs ?? []) {
|
|
227
|
+
const needle = input.trim().toLowerCase();
|
|
228
|
+
// A three-letter name matches half the passwords in the world; ignore short ones.
|
|
229
|
+
if (needle.length < 4 || !password.toLowerCase().includes(needle)) continue;
|
|
230
|
+
bits = Math.min(bits, 16);
|
|
231
|
+
warnings.push("It contains something you already typed on this form.");
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const run = longestRun(password);
|
|
236
|
+
if (run >= 3) {
|
|
237
|
+
bits -= (run - 2) * Math.log2(poolSize(password));
|
|
238
|
+
warnings.push("A character repeats several times in a row.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sequence = longestSequence(password);
|
|
242
|
+
if (sequence >= 4) {
|
|
243
|
+
bits -= sequence * Math.log2(poolSize(password)) * 0.75;
|
|
244
|
+
warnings.push("Part of it runs straight along the keyboard or the alphabet.");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (password.length < 8) warnings.push("Short passwords fall to a brute force whatever they contain.");
|
|
248
|
+
|
|
249
|
+
bits = Math.max(0, Math.round(bits));
|
|
250
|
+
const score: PasswordScore = bits < 28 ? 0 : bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4;
|
|
251
|
+
return { score, bits, warnings, empty: false };
|
|
252
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,13 @@ export { createMarquee, type MarqueeOptions, type MarqueeInstance, type MarqueeH
|
|
|
2
2
|
export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
|
|
3
3
|
export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
|
|
4
4
|
export { createButton, type ButtonOptions, type ButtonInstance, type ButtonState, type ButtonElement, type ButtonCooldown } from "@/core/button";
|
|
5
|
+
export { INPUT_ICON_PATHS, iconMarkup } from "@/core/input";
|
|
6
|
+
export {
|
|
7
|
+
generatePassword,
|
|
8
|
+
estimatePasswordStrength,
|
|
9
|
+
type GeneratePasswordOptions,
|
|
10
|
+
type PasswordAlphabet,
|
|
11
|
+
type EstimateOptions,
|
|
12
|
+
type PasswordStrengthReport,
|
|
13
|
+
type PasswordScore
|
|
14
|
+
} from "@/core/password";
|
package/src/react/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
export { useMarquee, type UseMarqueeResult } from "@/react/use-marquee";
|
|
2
4
|
export { type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
|
|
3
5
|
export { useInput, type UseInputResult } from "@/react/use-input";
|
|
@@ -6,3 +8,21 @@ export { type InputOptions, type InputAction, type InputIcon } from "@/core/inpu
|
|
|
6
8
|
export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
|
|
7
9
|
export { useButton, type UseButtonResult } from "@/react/use-button";
|
|
8
10
|
export { type ButtonOptions, type ButtonState } from "@/core/button";
|
|
11
|
+
export {
|
|
12
|
+
Input,
|
|
13
|
+
PasswordStrength,
|
|
14
|
+
type InputProps,
|
|
15
|
+
type PasswordStrengthProps,
|
|
16
|
+
type FieldAction,
|
|
17
|
+
type BreachChecker,
|
|
18
|
+
type BreachState,
|
|
19
|
+
type BreachStatus
|
|
20
|
+
} from "@/react/input";
|
|
21
|
+
export {
|
|
22
|
+
generatePassword,
|
|
23
|
+
estimatePasswordStrength,
|
|
24
|
+
type GeneratePasswordOptions,
|
|
25
|
+
type EstimateOptions,
|
|
26
|
+
type PasswordStrengthReport,
|
|
27
|
+
type PasswordScore
|
|
28
|
+
} from "@/core/password";
|