@enigmax/primitives 0.5.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-2DVVWPHM.js → chunk-42Y7OOJM.js} +343 -3
- package/dist/index.d.ts +148 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +149 -4
- package/dist/react/index.js +303 -3
- package/package.json +9 -6
- package/recipes/input/styles.css +54 -0
- package/recipes/input/tailwind.tsx +84 -0
- package/registry.json +104 -19
- package/src/core/button.ts +238 -0
- package/src/core/input.ts +26 -3
- package/src/core/password.ts +252 -0
- package/src/index.ts +11 -0
- package/src/react/index.ts +22 -0
- package/src/react/input.tsx +429 -0
- package/src/react/use-button.ts +89 -0
- package/recipes/input.css +0 -32
- package/recipes/input.css.tsx +0 -28
- package/recipes/input.tailwind.tsx +0 -44
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button behaviour: what makes it unavailable, and everything that follows from that.
|
|
3
|
+
*
|
|
4
|
+
* Disabled, loading and a cooldown are three reasons for the same state, so they collapse
|
|
5
|
+
* into one `available` the renderer reads, instead of three flags every call site has to
|
|
6
|
+
* combine correctly. The element to render is reported rather than chosen, because a
|
|
7
|
+
* framework-agnostic package cannot import next/link.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Which tag the consumer should render. An href makes it a link, and a link is not a button. */
|
|
11
|
+
export type ButtonElement = "button" | "a";
|
|
12
|
+
|
|
13
|
+
export interface ButtonCooldown {
|
|
14
|
+
/** How long the button stays unavailable after a press, in ms. */
|
|
15
|
+
ms: number;
|
|
16
|
+
/**
|
|
17
|
+
* Survive a reload under this key. Without it the cooldown is in memory only, and a
|
|
18
|
+
* refresh is a free retry - which is the whole thing a cooldown exists to prevent.
|
|
19
|
+
*/
|
|
20
|
+
key?: string;
|
|
21
|
+
storage?: "local" | "session";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ButtonOptions {
|
|
25
|
+
/** Turns into an `a`, and a link cannot be `disabled` - only `aria-disabled`. */
|
|
26
|
+
href?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Unavailable and busy. Set it yourself, or let an async `onPress` manage it. */
|
|
29
|
+
loading?: boolean;
|
|
30
|
+
/** ms, or the full shape for a cooldown that outlives a reload. */
|
|
31
|
+
cooldown?: number | ButtonCooldown;
|
|
32
|
+
/**
|
|
33
|
+
* A single key that presses the button. Ignored while the visitor is typing, and
|
|
34
|
+
* while any modifier is held, so it never steals a real shortcut.
|
|
35
|
+
*/
|
|
36
|
+
shortcut?: string;
|
|
37
|
+
/** Async work flips `loading` for its duration and only then starts the cooldown. */
|
|
38
|
+
onPress?: (event?: Event) => void | Promise<void>;
|
|
39
|
+
/** Called whenever anything below changes. */
|
|
40
|
+
onChange?: (state: ButtonState) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ButtonState {
|
|
44
|
+
/** The tag to render. */
|
|
45
|
+
element: ButtonElement;
|
|
46
|
+
/** Pressable: not disabled, not loading, not cooling down. */
|
|
47
|
+
available: boolean;
|
|
48
|
+
loading: boolean;
|
|
49
|
+
disabled: boolean;
|
|
50
|
+
/** ms left on the cooldown, 0 when there is none. */
|
|
51
|
+
cooldown: number;
|
|
52
|
+
/** The accessible name for the shortcut, when there is one. */
|
|
53
|
+
shortcut: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ButtonInstance {
|
|
57
|
+
readonly state: ButtonState;
|
|
58
|
+
/** Run the press as if it had been clicked. Ignored while unavailable. */
|
|
59
|
+
press(event?: Event): Promise<void>;
|
|
60
|
+
update(options: Partial<ButtonOptions>): void;
|
|
61
|
+
/** Clear a cooldown early, including its stored entry. */
|
|
62
|
+
reset(): void;
|
|
63
|
+
subscribe(listener: (state: ButtonState) => void): () => void;
|
|
64
|
+
destroy(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TICK_MS = 100;
|
|
68
|
+
|
|
69
|
+
function store(cooldown: ButtonCooldown): Storage | null {
|
|
70
|
+
if (!cooldown.key || typeof window === "undefined") return null;
|
|
71
|
+
try {
|
|
72
|
+
const target = cooldown.storage === "local" ? window.localStorage : window.sessionStorage;
|
|
73
|
+
const probe = "__enigma_probe__";
|
|
74
|
+
target.setItem(probe, "1");
|
|
75
|
+
target.removeItem(probe);
|
|
76
|
+
return target;
|
|
77
|
+
} catch {
|
|
78
|
+
// Private-mode Safari exposes the object and throws on write.
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalize(cooldown: ButtonOptions["cooldown"]): ButtonCooldown | null {
|
|
84
|
+
if (cooldown == null) return null;
|
|
85
|
+
return typeof cooldown === "number" ? { ms: cooldown } : cooldown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A shortcut must not fire while the visitor is writing, or it types into the page. */
|
|
89
|
+
function isTyping(target: EventTarget | null): boolean {
|
|
90
|
+
const element = target as HTMLElement | null;
|
|
91
|
+
if (!element) return false;
|
|
92
|
+
const tag = element.tagName;
|
|
93
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || element.isContentEditable === true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createButton(options: ButtonOptions = {}): ButtonInstance {
|
|
97
|
+
let opts: ButtonOptions = { ...options };
|
|
98
|
+
let loading = Boolean(opts.loading);
|
|
99
|
+
let readyAt = 0;
|
|
100
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
101
|
+
let destroyed = false;
|
|
102
|
+
const listeners = new Set<(state: ButtonState) => void>();
|
|
103
|
+
|
|
104
|
+
const storageKey = () => {
|
|
105
|
+
const cooldown = normalize(opts.cooldown);
|
|
106
|
+
return cooldown?.key ? `enigma:cooldown:${cooldown.key}` : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function restore(): void {
|
|
110
|
+
const cooldown = normalize(opts.cooldown);
|
|
111
|
+
if (!cooldown) return;
|
|
112
|
+
const target = store(cooldown);
|
|
113
|
+
const key = storageKey();
|
|
114
|
+
if (!target || !key) return;
|
|
115
|
+
const saved = Number(target.getItem(key));
|
|
116
|
+
// A stored time in the past is finished, not pending.
|
|
117
|
+
if (Number.isFinite(saved) && saved > Date.now()) readyAt = saved;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function remaining(): number {
|
|
121
|
+
return Math.max(0, readyAt - Date.now());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function snapshot(): ButtonState {
|
|
125
|
+
const disabled = Boolean(opts.disabled);
|
|
126
|
+
const cooldown = remaining();
|
|
127
|
+
return {
|
|
128
|
+
element: opts.href ? "a" : "button",
|
|
129
|
+
available: !disabled && !loading && cooldown === 0,
|
|
130
|
+
loading,
|
|
131
|
+
disabled,
|
|
132
|
+
cooldown,
|
|
133
|
+
shortcut: opts.shortcut ?? null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function emit(): void {
|
|
138
|
+
const state = snapshot();
|
|
139
|
+
opts.onChange?.(state);
|
|
140
|
+
for (const listener of listeners) listener(state);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stopTicking(): void {
|
|
144
|
+
if (timer === null) return;
|
|
145
|
+
clearInterval(timer);
|
|
146
|
+
timer = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startTicking(): void {
|
|
150
|
+
if (timer !== null || remaining() === 0) return;
|
|
151
|
+
timer = setInterval(() => {
|
|
152
|
+
if (remaining() > 0) { emit(); return; }
|
|
153
|
+
stopTicking();
|
|
154
|
+
emit();
|
|
155
|
+
}, TICK_MS);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function beginCooldown(): void {
|
|
159
|
+
const cooldown = normalize(opts.cooldown);
|
|
160
|
+
if (!cooldown || cooldown.ms <= 0) return;
|
|
161
|
+
readyAt = Date.now() + cooldown.ms;
|
|
162
|
+
const target = store(cooldown);
|
|
163
|
+
const key = storageKey();
|
|
164
|
+
if (target && key) {
|
|
165
|
+
try { target.setItem(key, String(readyAt)); } catch { /* quota */ }
|
|
166
|
+
}
|
|
167
|
+
startTicking();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function press(event?: Event): Promise<void> {
|
|
171
|
+
if (destroyed || !snapshot().available) return;
|
|
172
|
+
const result = opts.onPress?.(event);
|
|
173
|
+
|
|
174
|
+
if (result instanceof Promise) {
|
|
175
|
+
loading = true;
|
|
176
|
+
emit();
|
|
177
|
+
try {
|
|
178
|
+
await result;
|
|
179
|
+
} finally {
|
|
180
|
+
loading = false;
|
|
181
|
+
// The cooldown starts when the work FINISHES, not when it was asked for -
|
|
182
|
+
// otherwise a slow request eats its own cooldown and the button is free
|
|
183
|
+
// again the moment it returns.
|
|
184
|
+
beginCooldown();
|
|
185
|
+
emit();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
beginCooldown();
|
|
191
|
+
emit();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
195
|
+
if (!opts.shortcut || isTyping(event.target)) return;
|
|
196
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
197
|
+
if (event.key.toLowerCase() !== opts.shortcut.toLowerCase()) return;
|
|
198
|
+
if (!snapshot().available) return;
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
void press(event);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof window !== "undefined") {
|
|
204
|
+
restore();
|
|
205
|
+
startTicking();
|
|
206
|
+
window.addEventListener("keydown", onKeyDown);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
get state() { return snapshot(); },
|
|
211
|
+
press,
|
|
212
|
+
update(next: Partial<ButtonOptions>) {
|
|
213
|
+
const hadCooldown = JSON.stringify(normalize(opts.cooldown));
|
|
214
|
+
opts = { ...opts, ...next };
|
|
215
|
+
if (next.loading !== undefined) loading = Boolean(next.loading);
|
|
216
|
+
if (JSON.stringify(normalize(opts.cooldown)) !== hadCooldown) restore();
|
|
217
|
+
emit();
|
|
218
|
+
},
|
|
219
|
+
reset() {
|
|
220
|
+
readyAt = 0;
|
|
221
|
+
stopTicking();
|
|
222
|
+
const cooldown = normalize(opts.cooldown);
|
|
223
|
+
const key = storageKey();
|
|
224
|
+
if (cooldown && key) store(cooldown)?.removeItem(key);
|
|
225
|
+
emit();
|
|
226
|
+
},
|
|
227
|
+
subscribe(listener) {
|
|
228
|
+
listeners.add(listener);
|
|
229
|
+
return () => { listeners.delete(listener); };
|
|
230
|
+
},
|
|
231
|
+
destroy() {
|
|
232
|
+
destroyed = true;
|
|
233
|
+
stopTicking();
|
|
234
|
+
listeners.clear();
|
|
235
|
+
if (typeof window !== "undefined") window.removeEventListener("keydown", onKeyDown);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
package/src/core/input.ts
CHANGED
|
@@ -72,9 +72,32 @@ export interface InputInstance {
|
|
|
72
72
|
destroy(): void;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The built-in glyphs, as path data.
|
|
77
|
+
*
|
|
78
|
+
* Path data rather than markup because there are two renderers: this file writes an SVG
|
|
79
|
+
* string into a button it created, and the React component builds elements. Keeping the
|
|
80
|
+
* shapes here means one definition, and a theme that replaces an icon replaces it in both.
|
|
81
|
+
* Everything is stroked with `currentColor` at 1em, so an icon inherits the field's text.
|
|
82
|
+
*/
|
|
83
|
+
export const INPUT_ICON_PATHS = {
|
|
84
|
+
eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"],
|
|
85
|
+
eyeOff: [
|
|
86
|
+
"M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9",
|
|
87
|
+
"m2 2 20 20",
|
|
88
|
+
"M9.9 9.9a3 3 0 0 0 4.2 4.2"
|
|
89
|
+
],
|
|
90
|
+
generate: ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"]
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
/** The same shapes as a standalone SVG string, for the DOM renderer below. */
|
|
94
|
+
export function iconMarkup(paths: readonly string[]): string {
|
|
95
|
+
const body = paths.map((path) => `<path d="${path}"/>`).join("");
|
|
96
|
+
return `<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const EYE = iconMarkup(INPUT_ICON_PATHS.eye);
|
|
100
|
+
const EYE_OFF = iconMarkup(INPUT_ICON_PATHS.eyeOff);
|
|
78
101
|
|
|
79
102
|
function resolve<T>(value: T | ((state: InputActionState) => T), state: InputActionState): T {
|
|
80
103
|
return typeof value === "function" ? (value as (state: InputActionState) => T)(state) : value;
|
|
@@ -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
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
export { createMarquee, type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
|
|
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
|
+
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,6 +1,28 @@
|
|
|
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";
|
|
4
6
|
export { useSearch, type UseSearchResult } from "@/react/use-search";
|
|
5
7
|
export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
|
|
6
8
|
export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
|
|
9
|
+
export { useButton, type UseButtonResult } from "@/react/use-button";
|
|
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";
|