mevebu 2.1.0 → 2.3.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/README.md +7 -7
- package/mevebu/app/tailwind.css +69 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/registry.js +180 -0
- package/mevebu/libs/js/tools/aes-text.js +192 -0
- package/mevebu/libs/js/tools/bcrypt-hash.js +161 -0
- package/mevebu/libs/js/tools/file-hash.js +154 -0
- package/mevebu/libs/js/tools/passphrase-generator.js +149 -0
- package/mevebu/libs/js/tools/password-policy.js +139 -0
- package/mevebu/libs/js/tools/rsa-key-pair.js +137 -0
- package/mevebu/libs/js/tools/secret-scanner.js +168 -0
- package/mevebu/libs/js/tools/ssh-fingerprint.js +210 -0
- package/mevebu/libs/js/tools/totp-generator.js +178 -0
- package/mevebu/libs/js/tools/totp-verifier.js +183 -0
- package/mevebu/libs/js/totp.js +117 -0
- package/mevebu/libs/js/vendor/bcrypt.min.js +1220 -0
- package/mevebu/libs/js/vendor/bcryptjs.LICENSE +27 -0
- package/package.json +2 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Bcrypt, for testing a login. The hash and the check both run in this tab with a
|
|
2
|
+
// vendored pure JavaScript build, so no password leaves the page. The cost factor
|
|
3
|
+
// is the point: each step doubles the work, and the timing shows it.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, bench, stack, panel, textInput, numberInput, button, actions,
|
|
6
|
+
toolcard, notice, copyButton, output, chipRow,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
const COST_MIN = 4;
|
|
10
|
+
const COST_MAX = 14;
|
|
11
|
+
|
|
12
|
+
function bcryptLib() {
|
|
13
|
+
const lib = globalThis.dcodeIO?.bcrypt ?? globalThis.bcrypt;
|
|
14
|
+
if (!lib) throw new Error("The bcrypt engine did not load.");
|
|
15
|
+
return lib;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadEngine() {
|
|
19
|
+
if (globalThis.dcodeIO?.bcrypt || globalThis.bcrypt) return Promise.resolve(bcryptLib());
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const script = document.createElement("script");
|
|
22
|
+
script.src = new URL("../vendor/bcrypt.min.js", import.meta.url).href;
|
|
23
|
+
script.onload = () => {
|
|
24
|
+
try {
|
|
25
|
+
resolve(bcryptLib());
|
|
26
|
+
} catch (caught) {
|
|
27
|
+
reject(caught);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
script.onerror = () => reject(new Error("The bcrypt engine could not be loaded."));
|
|
31
|
+
document.head.append(script);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
36
|
+
|
|
37
|
+
export function mount(slot) {
|
|
38
|
+
let hashed = "";
|
|
39
|
+
let busy = false;
|
|
40
|
+
let token = 0;
|
|
41
|
+
|
|
42
|
+
const password = textInput({
|
|
43
|
+
type: "password",
|
|
44
|
+
placeholder: "The password to hash",
|
|
45
|
+
"aria-label": "Password",
|
|
46
|
+
autocomplete: "new-password",
|
|
47
|
+
});
|
|
48
|
+
const show = el("input", { type: "checkbox", class: "m-0 size-4 accent-[var(--jade)]" });
|
|
49
|
+
const cost = numberInput({ min: String(COST_MIN), max: String(COST_MAX), step: "1", value: "10", "aria-label": "Cost factor" });
|
|
50
|
+
const costLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "");
|
|
51
|
+
|
|
52
|
+
const existing = textInput({
|
|
53
|
+
placeholder: "$2b$10$...",
|
|
54
|
+
"aria-label": "Hash to check",
|
|
55
|
+
class: "font-mono text-sm",
|
|
56
|
+
autocomplete: "off",
|
|
57
|
+
spellcheck: "false",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const error = notice("warn");
|
|
61
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
62
|
+
const hashOut = output({ rows: 3 });
|
|
63
|
+
const verdict = el("p", { class: "m-0 text-sm font-semibold" });
|
|
64
|
+
|
|
65
|
+
function syncCost() {
|
|
66
|
+
const value = Math.max(COST_MIN, Math.min(COST_MAX, Number(cost.value) || 10));
|
|
67
|
+
costLabel.textContent = `${value} · about ${2 ** value} rounds`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function hash() {
|
|
71
|
+
if (busy) return;
|
|
72
|
+
if (!password.value) {
|
|
73
|
+
error.write("Type a password first.");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
busy = true;
|
|
77
|
+
const current = ++token;
|
|
78
|
+
error.write("");
|
|
79
|
+
summary.textContent = "Hashing";
|
|
80
|
+
try {
|
|
81
|
+
const lib = await loadEngine();
|
|
82
|
+
const rounds = Math.max(COST_MIN, Math.min(COST_MAX, Number(cost.value) || 10));
|
|
83
|
+
await tick();
|
|
84
|
+
const started = performance.now();
|
|
85
|
+
const salt = lib.genSaltSync(rounds);
|
|
86
|
+
const result = lib.hashSync(password.value, salt);
|
|
87
|
+
const took = performance.now() - started;
|
|
88
|
+
if (current !== token) return;
|
|
89
|
+
hashed = result;
|
|
90
|
+
hashOut.write(result);
|
|
91
|
+
summary.textContent = `cost ${rounds} · ${Math.round(took)} ms on this device`;
|
|
92
|
+
toast("Hash ready");
|
|
93
|
+
} catch (caught) {
|
|
94
|
+
if (current !== token) return;
|
|
95
|
+
error.write(caught?.message || "The hash could not be made.");
|
|
96
|
+
} finally {
|
|
97
|
+
busy = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function check() {
|
|
102
|
+
const stored = existing.value.trim();
|
|
103
|
+
verdict.textContent = "";
|
|
104
|
+
if (!stored) return;
|
|
105
|
+
if (!/^\$2[abxy]\$\d{2}\$.{53}$/.test(stored)) {
|
|
106
|
+
verdict.textContent = "That is not a bcrypt hash. It should start with $2b$ and be 60 characters.";
|
|
107
|
+
verdict.className = "m-0 text-sm font-semibold text-danger";
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!password.value) {
|
|
111
|
+
verdict.textContent = "Type the password to check against that hash.";
|
|
112
|
+
verdict.className = "m-0 text-sm font-semibold text-ink-2";
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const lib = await loadEngine();
|
|
117
|
+
const ok = lib.compareSync(password.value, stored);
|
|
118
|
+
verdict.textContent = ok ? "Match: this password produces that hash." : "No match for that password.";
|
|
119
|
+
verdict.className = ok ? "m-0 text-sm font-semibold text-jade" : "m-0 text-sm font-semibold text-danger";
|
|
120
|
+
} catch (caught) {
|
|
121
|
+
verdict.textContent = caught?.message || "The check could not run.";
|
|
122
|
+
verdict.className = "m-0 text-sm font-semibold text-danger";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
show.addEventListener("change", () => {
|
|
127
|
+
password.type = show.checked ? "text" : "password";
|
|
128
|
+
});
|
|
129
|
+
password.addEventListener("input", check);
|
|
130
|
+
existing.addEventListener("input", check);
|
|
131
|
+
cost.addEventListener("input", syncCost);
|
|
132
|
+
|
|
133
|
+
const copy = copyButton(() => hashed, "Copy hash");
|
|
134
|
+
const hashButton = button("Hash password", { icon: "hash", kind: "fill", onclick: hash });
|
|
135
|
+
|
|
136
|
+
syncCost();
|
|
137
|
+
|
|
138
|
+
const shell = toolcard({
|
|
139
|
+
body: bench(
|
|
140
|
+
stack(
|
|
141
|
+
panel("Password", password),
|
|
142
|
+
el("label", { class: "inline-flex min-h-9 cursor-pointer items-center gap-2 text-sm text-ink-2" }, show, "Show password"),
|
|
143
|
+
panel("Cost factor", cost, "Each step doubles the work. 10 is a common default; 12 is slower and safer."),
|
|
144
|
+
costLabel,
|
|
145
|
+
panel("Hash to check", existing, "Paste a stored hash to see whether the password matches it."),
|
|
146
|
+
verdict,
|
|
147
|
+
error
|
|
148
|
+
),
|
|
149
|
+
stack(summary, hashOut,
|
|
150
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
151
|
+
"Bcrypt is a password hash, not encryption. The salt is in the hash, so the same password gives a different result each time. Use the timing to pick a cost your server can afford."))
|
|
152
|
+
),
|
|
153
|
+
foot: actions(copy, hashButton),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
slot.append(shell.node);
|
|
157
|
+
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default { mount };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The hash of a file, to compare against a published checksum. The file is read
|
|
2
|
+
// here with Web Crypto, so a download can be verified before it is opened.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, bench, stack, panel, textInput, button, actions, toolcard,
|
|
5
|
+
blank, notice, copyButton, output,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, formatBytes } from "../files.js";
|
|
8
|
+
|
|
9
|
+
const ALGORITHMS = [
|
|
10
|
+
{ id: "SHA-1", label: "SHA-1" },
|
|
11
|
+
{ id: "SHA-256", label: "SHA-256" },
|
|
12
|
+
{ id: "SHA-512", label: "SHA-512" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function hex(bytes) {
|
|
16
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function mount(slot) {
|
|
20
|
+
let file = null;
|
|
21
|
+
let digests = {};
|
|
22
|
+
|
|
23
|
+
const expected = textInput({
|
|
24
|
+
placeholder: "Paste the published checksum",
|
|
25
|
+
"aria-label": "Expected checksum",
|
|
26
|
+
class: "font-mono text-sm",
|
|
27
|
+
autocomplete: "off",
|
|
28
|
+
spellcheck: "false",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const error = notice("warn");
|
|
32
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
33
|
+
const verdictTitle = el("p", { class: "m-0 text-lg font-bold" }, "Waiting");
|
|
34
|
+
const verdictBody = el("p", { class: "m-0 text-sm text-ink-2" }, "Drop a file, then paste the checksum you were given.");
|
|
35
|
+
const verdict = el("div", { class: "grid gap-1 rounded-r2 border border-orbit bg-deck-2 p-4" }, verdictTitle, verdictBody);
|
|
36
|
+
const rows = el("div", { class: "grid gap-2" });
|
|
37
|
+
const empty = blank("file-key", "Drop a file to hash it.");
|
|
38
|
+
|
|
39
|
+
const drop = dropzone({ label: "Drop a file here", accept: "", iconName: "file-key" });
|
|
40
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
41
|
+
|
|
42
|
+
function paint(state, title, body) {
|
|
43
|
+
const tone = state === "ok"
|
|
44
|
+
? "border-jade bg-jade-soft"
|
|
45
|
+
: state === "bad"
|
|
46
|
+
? "border-danger/40 bg-danger/10"
|
|
47
|
+
: "border-orbit bg-deck-2";
|
|
48
|
+
verdict.className = `grid gap-1 rounded-r2 border p-4 ${tone}`;
|
|
49
|
+
verdictTitle.textContent = title;
|
|
50
|
+
verdictTitle.className = `m-0 text-lg font-bold ${state === "ok" ? "text-jade" : state === "bad" ? "text-danger" : ""}`;
|
|
51
|
+
verdictBody.textContent = body;
|
|
52
|
+
verdictBody.className = state === "ok" || state === "bad" ? "m-0 text-sm" : "m-0 text-sm text-ink-2";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compare() {
|
|
56
|
+
if (!file) {
|
|
57
|
+
paint("idle", "Waiting", "Drop a file, then paste the checksum you were given.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const wanted = expected.value.replace(/\s+/g, "").toLowerCase();
|
|
61
|
+
if (!wanted) {
|
|
62
|
+
paint("idle", "Ready", "Paste a checksum to compare, or copy one below.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const match = Object.entries(digests).find(([, value]) => value === wanted);
|
|
66
|
+
if (match) {
|
|
67
|
+
paint("ok", "Match", `The file matches the ${match[0]} you pasted.`);
|
|
68
|
+
} else {
|
|
69
|
+
const length = wanted.length;
|
|
70
|
+
const sameLength = Object.entries(digests).find(([, value]) => value.length === length);
|
|
71
|
+
paint(
|
|
72
|
+
"bad",
|
|
73
|
+
"No match",
|
|
74
|
+
sameLength
|
|
75
|
+
? `That is not the ${sameLength[0]} of this file. Either the file or the checksum is wrong.`
|
|
76
|
+
: `A checksum of ${length} characters is not a SHA-1, SHA-256 or SHA-512 digest.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderRows() {
|
|
82
|
+
rows.replaceChildren(
|
|
83
|
+
...ALGORITHMS.map((algorithm) =>
|
|
84
|
+
el("div", { class: "grid gap-1.5 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
|
|
85
|
+
el("div", { class: "flex items-center justify-between gap-2" },
|
|
86
|
+
el("span", { class: "text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase" }, algorithm.label),
|
|
87
|
+
copyButton(() => digests[algorithm.id] ?? "", "Copy")),
|
|
88
|
+
el("p", { class: "m-0 wrap-anywhere font-mono text-2xs leading-[1.6] text-ink" }, digests[algorithm.id] ?? "")))
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function open(files) {
|
|
93
|
+
const picked = Array.from(files)[0];
|
|
94
|
+
if (!picked) return;
|
|
95
|
+
error.write("");
|
|
96
|
+
summary.textContent = `Reading ${picked.name}`;
|
|
97
|
+
try {
|
|
98
|
+
const bytes = await picked.arrayBuffer();
|
|
99
|
+
const next = {};
|
|
100
|
+
for (const algorithm of ALGORITHMS) {
|
|
101
|
+
const digest = await crypto.subtle.digest(algorithm.id, bytes);
|
|
102
|
+
next[algorithm.id] = hex(new Uint8Array(digest));
|
|
103
|
+
}
|
|
104
|
+
file = picked;
|
|
105
|
+
digests = next;
|
|
106
|
+
summary.textContent = `${picked.name} · ${formatBytes(picked.size)}`;
|
|
107
|
+
empty.hidden = true;
|
|
108
|
+
rows.hidden = false;
|
|
109
|
+
renderRows();
|
|
110
|
+
compare();
|
|
111
|
+
} catch (caught) {
|
|
112
|
+
summary.textContent = "";
|
|
113
|
+
error.write(caught?.message || "This file could not be read.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
drop.onFiles(open);
|
|
118
|
+
expected.addEventListener("input", compare);
|
|
119
|
+
clearButton.addEventListener("click", () => {
|
|
120
|
+
file = null;
|
|
121
|
+
digests = {};
|
|
122
|
+
expected.value = "";
|
|
123
|
+
summary.textContent = "";
|
|
124
|
+
error.write("");
|
|
125
|
+
rows.replaceChildren();
|
|
126
|
+
rows.hidden = true;
|
|
127
|
+
empty.hidden = false;
|
|
128
|
+
paint("idle", "Waiting", "Drop a file, then paste the checksum you were given.");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
paint("idle", "Waiting", "Drop a file, then paste the checksum you were given.");
|
|
132
|
+
rows.hidden = true;
|
|
133
|
+
|
|
134
|
+
const shell = toolcard({
|
|
135
|
+
body: bench(
|
|
136
|
+
stack(
|
|
137
|
+
drop,
|
|
138
|
+
panel("Expected checksum", expected, "SHA-1, SHA-256 or SHA-512. Case and spaces are ignored."),
|
|
139
|
+
verdict,
|
|
140
|
+
error,
|
|
141
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
142
|
+
"The file is read in this tab. Nothing is uploaded, and the size limit is whatever the browser can hold in memory.")
|
|
143
|
+
),
|
|
144
|
+
stack(summary, empty, rows)
|
|
145
|
+
),
|
|
146
|
+
foot: actions(clearButton),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
slot.append(shell.node);
|
|
150
|
+
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default { mount };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A passphrase from a bundled word list. The entropy reading is arithmetic on
|
|
2
|
+
// the size of that list, not an estimate: log2(length) bits per word.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, bench, stack, panel, numberInput, selectInput, button,
|
|
5
|
+
actions, toolcard, notice, checkbox, rangeInput, copyButton, chipRow,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
|
|
8
|
+
const WORDS = `acorn amber anchor apple arrow aspen attic autumn badge bagel baker bamboo
|
|
9
|
+
banjo barrel basil basket beach beacon bean bear beaver bell belt bench berry birch biscuit
|
|
10
|
+
bison blade blanket bloom board boat bolt bonfire book boot bottle boulder bowl box braid branch
|
|
11
|
+
brass bread brick bridge broom brush bubble bucket buffalo bugle bulb bunny burrow bus butter
|
|
12
|
+
button cabin cactus camel candle candy canoe canvas canyon carbon cargo carpet carrot cart castle
|
|
13
|
+
cedar chalk cherry chess chest chicken chisel cider cinema circle city clamp clay cliff cloak clock
|
|
14
|
+
cloud clover coach coast cobra cocoa comet compass copper coral cork corn cotton couch cougar
|
|
15
|
+
country court cove crab crane crater crayon creek crest cricket crimson crow crown crystal cube
|
|
16
|
+
cupboard curl curtain cycle daisy dance dawn deer delta denim desert desk diamond dice diner dock
|
|
17
|
+
dolphin dome donkey door dove dragon drift drum dune dusk eagle earth easel echo egg elbow elder
|
|
18
|
+
elk ember engine envelope falcon fang farm feather fence fern ferry field fig finch fire fish flag
|
|
19
|
+
flame flask fleet flint float flock flute foam fog forest fork fossil fountain fox frame frost fruit
|
|
20
|
+
galley garden garlic gate gecko gem ginger glacier glass glove goat gold goose gourd grain grape
|
|
21
|
+
grass gravel green grove guitar gull gust hammer harbor harp harvest hawk hay hedge helmet heron
|
|
22
|
+
hill hinge hive holly honey hoof hook horn horse hotel hut iceberg ink inlet iron island ivory ivy
|
|
23
|
+
jacket jade jaguar jar jasmine jelly jewel jigsaw jungle juniper kayak kettle key kite kitten kiwi
|
|
24
|
+
knife knot koala ladder lagoon lake lamp lantern lapel laser latch lattice leaf ledge lemon lens
|
|
25
|
+
letter lever lily lime linen lion lizard lobster lock log lotus lumber lunar lynx magnet magneto
|
|
26
|
+
maize mango maple marble march marsh mask mast meadow medal melon mesa metal meteor mint mirror
|
|
27
|
+
mist mitten mole monkey moon moose mortar moss moth motor mound mountain mouse mushroom music
|
|
28
|
+
napkin needle nest nettle nickel night noodle north note nugget oak oasis ocean olive onion opal
|
|
29
|
+
orange orbit orchid otter oven owl oxide oyster paddle page palm panda panel paper parcel parrot
|
|
30
|
+
pasture patio peach peanut pear pebble pelican pen pencil pepper perch petal piano picnic pier
|
|
31
|
+
pigeon pillar pine piston pitcher plain plane planet plank plant plaster plate plaza plum pocket
|
|
32
|
+
pond pony poppy porch portal potato prairie prism pumpkin puzzle quail quarry quartz quill quiver
|
|
33
|
+
rabbit radar raft rail rain rake ram ranch raven ravine ray razor reef relay ribbon rice ridge
|
|
34
|
+
rifle ring river road robin rocket rod roof root rope rose rowboat ruby rug ruler saddle sage sail
|
|
35
|
+
salmon sand sapphire satchel saw scarf school scooter scout sea seal seed shade shark sheep shelf
|
|
36
|
+
shell shield ship shoe shore shovel shrub silk silver siren skate sketch sky sled slope snail snake
|
|
37
|
+
snow soap sock sofa solar song soup sparrow spice spider spike spine sponge spoon spring spruce
|
|
38
|
+
square squid stable stack star statue steam steel stem step stick stone stool storm stove strap
|
|
39
|
+
stream street string sugar summer sun swan sweat sweet swift swing table tackle tail talon tank
|
|
40
|
+
tapir tea teal temple tent thistle thorn thread thunder tide tiger timber tin toad toast tomato
|
|
41
|
+
tone tool tooth topaz torch tower track trail train trap tray tree trek trench tribe trout truck
|
|
42
|
+
trumpet trunk tulip tunnel turtle twig valley vanilla vase velvet vine violet violin vista wagon
|
|
43
|
+
walnut walrus wand wave wax wheat wheel whip whisper willow wind window wing winter wire wolf wood
|
|
44
|
+
wool wrench yacht yarn yellow yew zebra zinc zipper zone`.split(/\s+/).filter(Boolean);
|
|
45
|
+
|
|
46
|
+
const SEPARATORS = [
|
|
47
|
+
{ value: "-", label: "hyphen" },
|
|
48
|
+
{ value: " ", label: "space" },
|
|
49
|
+
{ value: ".", label: "dot" },
|
|
50
|
+
{ value: "_", label: "underscore" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export function mount(slot) {
|
|
54
|
+
let phrase = "";
|
|
55
|
+
|
|
56
|
+
const count = rangeInput({ min: "2", max: "12", step: "1", value: "5", "aria-label": "Words" });
|
|
57
|
+
const countLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "5 words");
|
|
58
|
+
const separator = selectInput(SEPARATORS, { value: "-", "aria-label": "Separator" });
|
|
59
|
+
const capitalize = checkbox("Capitalize each word", { checked: false });
|
|
60
|
+
const number = checkbox("Add a number", { checked: false });
|
|
61
|
+
const symbol = checkbox("Add a symbol", { checked: false });
|
|
62
|
+
|
|
63
|
+
const error = notice("warn");
|
|
64
|
+
const output = el("p", {
|
|
65
|
+
class: "m-0 wrap-anywhere font-mono text-[clamp(20px,3.4vw,28px)] font-bold leading-[1.4] tracking-[-0.01em] text-ink",
|
|
66
|
+
"aria-live": "polite",
|
|
67
|
+
}, "");
|
|
68
|
+
const entropy = el("p", { class: "m-0 text-sm text-ink-2" });
|
|
69
|
+
const panelBody = el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-4" }, output, entropy);
|
|
70
|
+
|
|
71
|
+
function make() {
|
|
72
|
+
error.write("");
|
|
73
|
+
if (!WORDS.length) {
|
|
74
|
+
error.write("The word list is empty.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const size = Math.max(2, Math.min(12, Number(count.value) || 5));
|
|
78
|
+
const random = crypto.getRandomValues(new Uint32Array(size + 2));
|
|
79
|
+
const parts = [];
|
|
80
|
+
for (let i = 0; i < size; i++) {
|
|
81
|
+
let word = WORDS[random[i] % WORDS.length];
|
|
82
|
+
if (capitalize.input.checked) word = word[0].toUpperCase() + word.slice(1);
|
|
83
|
+
parts.push(word);
|
|
84
|
+
}
|
|
85
|
+
if (number.input.checked) parts.push(String(random[size] % 100));
|
|
86
|
+
if (symbol.input.checked) parts.push("!@#$%&*?"[random[size + 1] % 9]);
|
|
87
|
+
|
|
88
|
+
const join = separator.value === " " ? " " : separator.value;
|
|
89
|
+
phrase = parts.join(join);
|
|
90
|
+
|
|
91
|
+
// Each word is log2(WORDS.length) bits, because every index is equally
|
|
92
|
+
// likely. The optional number and symbol add about 6.6 and 3.2 bits.
|
|
93
|
+
const bits = size * Math.log2(WORDS.length) + (number.input.checked ? Math.log2(100) : 0) + (symbol.input.checked ? Math.log2(9) : 0);
|
|
94
|
+
// Half the space on average, at 100 billion guesses a second.
|
|
95
|
+
const seconds = 2 ** (bits - 1) / 1e11;
|
|
96
|
+
entropy.textContent = `${Math.round(bits)} bits · ${formatTime(seconds)} to guess at 100 billion tries a second`;
|
|
97
|
+
output.textContent = phrase;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatTime(seconds) {
|
|
101
|
+
if (seconds < 1) return "under a second";
|
|
102
|
+
if (seconds < 60) return `about ${Math.round(seconds)} seconds`;
|
|
103
|
+
const years = seconds / 31_557_600;
|
|
104
|
+
if (seconds < 3600) return `about ${Math.max(1, Math.round(seconds / 60))} minutes`;
|
|
105
|
+
if (seconds < 86400) return `about ${Math.round(seconds / 3600)} hours`;
|
|
106
|
+
if (years < 1) return `about ${Math.round(seconds / 86400)} days`;
|
|
107
|
+
if (years < 1000) return `about ${Math.round(years)} years`;
|
|
108
|
+
if (years < 1e6) return `about ${Math.round(years / 1000)} thousand years`;
|
|
109
|
+
if (years < 1e9) return `about ${Math.round(years / 1e6)} million years`;
|
|
110
|
+
return "longer than the universe has existed";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
count.addEventListener("input", () => {
|
|
114
|
+
countLabel.textContent = `${count.value} word${count.value === "1" ? "" : "s"}`;
|
|
115
|
+
make();
|
|
116
|
+
});
|
|
117
|
+
separator.addEventListener("change", make);
|
|
118
|
+
capitalize.input.addEventListener("change", make);
|
|
119
|
+
number.input.addEventListener("change", make);
|
|
120
|
+
symbol.input.addEventListener("change", make);
|
|
121
|
+
|
|
122
|
+
const regenerate = button("Generate", { icon: "refresh-cw", kind: "fill", onclick: make });
|
|
123
|
+
const copy = copyButton(() => phrase, "Copy passphrase");
|
|
124
|
+
|
|
125
|
+
make();
|
|
126
|
+
|
|
127
|
+
const shell = toolcard({
|
|
128
|
+
body: bench(
|
|
129
|
+
stack(
|
|
130
|
+
el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Words"), count, countLabel),
|
|
131
|
+
panel("Separator", separator),
|
|
132
|
+
capitalize,
|
|
133
|
+
number,
|
|
134
|
+
symbol,
|
|
135
|
+
error
|
|
136
|
+
),
|
|
137
|
+
stack(panelBody,
|
|
138
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
139
|
+
"The word list is bundled with the app, so this works offline. Each word is worth about 9 bits; five words is about 46 bits, which suits a login with rate limiting. Use more words for anything that protects money."))
|
|
140
|
+
),
|
|
141
|
+
foot: actions(regenerate, copy),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
slot.append(shell.node);
|
|
145
|
+
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default { mount };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Turns a written password rule into the three things a team needs: the rule in
|
|
2
|
+
// plain words, a regex that enforces it, and a checklist that tests a sample.
|
|
3
|
+
// The regex is built from the same settings as the text, so the two never drift.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, bench, stack, panel, numberInput, checkbox, textInput, button,
|
|
6
|
+
actions, toolcard, notice, copyButton, output,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
function escapeClass(chars) {
|
|
10
|
+
return chars.replace(/[\]\\^-]/g, "\\$&");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mount(slot) {
|
|
14
|
+
const minimum = numberInput({ min: "1", max: "128", step: "1", value: "12", "aria-label": "Minimum length" });
|
|
15
|
+
const maximum = numberInput({ min: "0", max: "512", step: "1", value: "0", "aria-label": "Maximum length, 0 for none" });
|
|
16
|
+
const lower = checkbox("Lowercase letters", { checked: true });
|
|
17
|
+
const upper = checkbox("Uppercase letters", { checked: true });
|
|
18
|
+
const digits = checkbox("Digits", { checked: true });
|
|
19
|
+
const symbols = checkbox("Symbols", { checked: false });
|
|
20
|
+
const noSpaces = checkbox("No spaces", { checked: true });
|
|
21
|
+
const noRepeat = numberInput({ min: "0", max: "10", step: "1", value: "0", "aria-label": "Max repeats in a row, 0 for none" });
|
|
22
|
+
const symbolSet = textInput({ value: "!@#$%^&*()-_=+[]{};:,.?", "aria-label": "Allowed symbols", class: "font-mono text-sm" });
|
|
23
|
+
const sample = textInput({ placeholder: "Type a password to test it", "aria-label": "Sample password", class: "font-mono", autocomplete: "off" });
|
|
24
|
+
|
|
25
|
+
const error = notice("warn");
|
|
26
|
+
const rule = el("p", { class: "m-0 text-sm leading-[1.6] text-ink" });
|
|
27
|
+
const pattern = output({ rows: 2 });
|
|
28
|
+
const checks = el("div", { class: "grid gap-1.5" });
|
|
29
|
+
const verdict = el("p", { class: "m-0 text-sm font-semibold" });
|
|
30
|
+
|
|
31
|
+
function classes() {
|
|
32
|
+
const out = [];
|
|
33
|
+
if (lower.input.checked) out.push({ label: "lowercase letter", test: (s) => /[a-z]/.test(s), rx: "(?=.*[a-z])" });
|
|
34
|
+
if (upper.input.checked) out.push({ label: "uppercase letter", test: (s) => /[A-Z]/.test(s), rx: "(?=.*[A-Z])" });
|
|
35
|
+
if (digits.input.checked) out.push({ label: "digit", test: (s) => /\d/.test(s), rx: "(?=.*\\d)" });
|
|
36
|
+
if (symbols.input.checked) {
|
|
37
|
+
const set = symbolSet.value;
|
|
38
|
+
const safe = escapeClass(set);
|
|
39
|
+
out.push({
|
|
40
|
+
label: `symbol from ${set || "the set"}`,
|
|
41
|
+
test: (s) => [...s].some((char) => set.includes(char)),
|
|
42
|
+
rx: `(?=.*[${safe}])`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function build() {
|
|
49
|
+
error.write("");
|
|
50
|
+
const min = Math.max(1, Number(minimum.value) || 1);
|
|
51
|
+
const max = Number(maximum.value) || 0;
|
|
52
|
+
const kinds = classes();
|
|
53
|
+
const repeat = Number(noRepeat.value) || 0;
|
|
54
|
+
|
|
55
|
+
if (max && max < min) error.write("The maximum is below the minimum, so no password can pass.");
|
|
56
|
+
if (!kinds.length && !noSpaces.input.checked) error.write("Pick at least one character class, or the rule accepts anything.");
|
|
57
|
+
|
|
58
|
+
const parts = [`at least ${min} characters`];
|
|
59
|
+
if (max) parts.push(`at most ${max} characters`);
|
|
60
|
+
for (const kind of kinds) parts.push(`at least one ${kind.label}`);
|
|
61
|
+
if (noSpaces.input.checked) parts.push("no spaces");
|
|
62
|
+
if (repeat) parts.push(`no more than ${repeat} identical characters in a row`);
|
|
63
|
+
rule.textContent = `A password needs ${listSentence(parts)}.`;
|
|
64
|
+
|
|
65
|
+
const lookaheads = kinds.map((kind) => kind.rx).join("");
|
|
66
|
+
const length = max ? `{${min},${max}}` : `{${min},}`;
|
|
67
|
+
const space = noSpaces.input.checked ? "(?!.*\\s)" : "";
|
|
68
|
+
const repeats = repeat ? `(?!.*(.)\\1{${repeat}})` : "";
|
|
69
|
+
const regex = `^${lookaheads}${space}${repeats}.${length}$`;
|
|
70
|
+
pattern.write(regex);
|
|
71
|
+
|
|
72
|
+
checks.replaceChildren();
|
|
73
|
+
const samples = sample.value;
|
|
74
|
+
const lengthOk = samples.length >= min && (!max || samples.length <= max);
|
|
75
|
+
const rows = [
|
|
76
|
+
{ label: `${min}${max ? `–${max}` : "+"} characters`, ok: samples.length > 0 ? lengthOk : null },
|
|
77
|
+
...kinds.map((kind) => ({ label: kind.label, ok: samples ? kind.test(samples) : null })),
|
|
78
|
+
];
|
|
79
|
+
if (noSpaces.input.checked) rows.push({ label: "no spaces", ok: samples ? !/\s/.test(samples) : null });
|
|
80
|
+
if (repeat) rows.push({ label: `no more than ${repeat} repeats in a row`, ok: samples ? !new RegExp(`(.)\\1{${repeat}}`).test(samples) : null });
|
|
81
|
+
|
|
82
|
+
checks.replaceChildren(
|
|
83
|
+
...rows.map((row) =>
|
|
84
|
+
el("div", { class: "flex items-center gap-2 text-sm" },
|
|
85
|
+
el("span", {
|
|
86
|
+
class: `inline-flex size-5 flex-none items-center justify-center rounded-full text-2xs font-bold ${row.ok === null ? "bg-deck-2 text-ink-3" : row.ok ? "bg-jade-soft text-jade" : "bg-danger/15 text-danger"}`,
|
|
87
|
+
}, row.ok === null ? "·" : row.ok ? "✓" : "×"),
|
|
88
|
+
el("span", { class: row.ok === false ? "text-danger" : "text-ink-2" }, row.label)))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!samples) verdict.textContent = "";
|
|
92
|
+
else {
|
|
93
|
+
const failed = rows.filter((row) => row.ok === false).length;
|
|
94
|
+
verdict.textContent = failed === 0 ? "This password passes the rule." : `This password fails ${failed} part${failed === 1 ? "" : "s"} of the rule.`;
|
|
95
|
+
verdict.className = `m-0 text-sm font-semibold ${failed === 0 ? "text-jade" : "text-danger"}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listSentence(items) {
|
|
100
|
+
if (items.length <= 1) return items.join("");
|
|
101
|
+
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const node of [minimum, maximum, noRepeat, symbolSet, sample]) node.addEventListener("input", build);
|
|
105
|
+
for (const node of [lower, upper, digits, symbols, noSpaces]) node.input.addEventListener("change", build);
|
|
106
|
+
|
|
107
|
+
const copyRule = copyButton(() => `${rule.textContent}\nRegex: ${pattern.textContent}`, "Copy policy");
|
|
108
|
+
|
|
109
|
+
build();
|
|
110
|
+
|
|
111
|
+
const shell = toolcard({
|
|
112
|
+
body: bench(
|
|
113
|
+
stack(
|
|
114
|
+
el("div", { class: "grid grid-cols-2 gap-3" }, panel("Minimum", minimum), panel("Maximum", maximum)),
|
|
115
|
+
el("div", { class: "grid gap-1.5" },
|
|
116
|
+
el("span", { class: "text-sm font-semibold text-ink-2" }, "Character classes"),
|
|
117
|
+
el("div", { class: "grid grid-cols-2 gap-2" }, lower, upper, digits, symbols),
|
|
118
|
+
noSpaces),
|
|
119
|
+
panel("Allowed symbols", symbolSet),
|
|
120
|
+
panel("Max repeats in a row", noRepeat, "0 means no limit."),
|
|
121
|
+
error
|
|
122
|
+
),
|
|
123
|
+
stack(
|
|
124
|
+
el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-4" }, rule),
|
|
125
|
+
panel("Regex", pattern),
|
|
126
|
+
panel("Test a password", sample, "Shown in the page only. Nothing is sent."),
|
|
127
|
+
checks,
|
|
128
|
+
verdict
|
|
129
|
+
)
|
|
130
|
+
),
|
|
131
|
+
foot: actions(copyRule),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
slot.append(shell.node);
|
|
135
|
+
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default { mount };
|