mevebu 2.2.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 +12 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/registry.js +90 -0
- package/mevebu/libs/js/tools/bcrypt-hash.js +161 -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/vendor/bcrypt.min.js +1220 -0
- package/mevebu/libs/js/vendor/bcryptjs.LICENSE +27 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# mevebu
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**215 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
|
|
6
6
|
|
|
7
7
|
A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
|
|
|
10
10
|
[](https://www.npmjs.com/package/mevebu)
|
|
11
11
|
[](./LICENSE)
|
|
12
12
|
[](https://github.com/mevebu/mevebu/stargazers)
|
|
13
|
-

|
|
14
14
|

|
|
15
15
|
|
|
16
16
|
[Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Credit](#credit) · [Development](#development) · [FAQ](#faq)
|
|
@@ -23,7 +23,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
|
|
|
23
23
|
|
|
24
24
|
## What is mevebu?
|
|
25
25
|
|
|
26
|
-
mevebu is a collection of **
|
|
26
|
+
mevebu is a collection of **215 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
|
|
27
27
|
|
|
28
28
|
Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
|
|
29
29
|
|
|
@@ -64,12 +64,12 @@ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free p
|
|
|
64
64
|
|
|
65
65
|
## What's inside
|
|
66
66
|
|
|
67
|
-
mevebu has **
|
|
67
|
+
mevebu has **215 tools in 20 categories**. Here is the full breakdown:
|
|
68
68
|
|
|
69
69
|
| Category | Tools |
|
|
70
70
|
| ------------------ | ----: |
|
|
71
71
|
| Data Generator | 10 |
|
|
72
|
-
| Passwords and keys |
|
|
72
|
+
| Passwords and keys | 20 |
|
|
73
73
|
| Developer | 21 |
|
|
74
74
|
| Convert | 15 |
|
|
75
75
|
| Text and data | 14 |
|
|
@@ -88,7 +88,7 @@ mevebu has **210 tools in 20 categories**. Here is the full breakdown:
|
|
|
88
88
|
| GitHub | 8 |
|
|
89
89
|
| Units | 8 |
|
|
90
90
|
| QR codes | 2 |
|
|
91
|
-
| **Total** | **
|
|
91
|
+
| **Total** | **215** |
|
|
92
92
|
|
|
93
93
|
The counts come straight from the tool registry, so this table and the app never drift apart.
|
|
94
94
|
|
|
@@ -176,7 +176,7 @@ That is all. The catalog, search, breadcrumbs, and category pages pick it up aut
|
|
|
176
176
|
## FAQ
|
|
177
177
|
|
|
178
178
|
**What is mevebu?**
|
|
179
|
-
mevebu is a free, open source collection of
|
|
179
|
+
mevebu is a free, open source collection of 215 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
|
|
180
180
|
|
|
181
181
|
**Is mevebu really free?**
|
|
182
182
|
Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
|
package/mevebu/app/tailwind.css
CHANGED
|
@@ -578,6 +578,12 @@
|
|
|
578
578
|
.min-h-24 {
|
|
579
579
|
min-height: calc(var(--spacing) * 24);
|
|
580
580
|
}
|
|
581
|
+
.min-h-28 {
|
|
582
|
+
min-height: calc(var(--spacing) * 28);
|
|
583
|
+
}
|
|
584
|
+
.min-h-32 {
|
|
585
|
+
min-height: calc(var(--spacing) * 32);
|
|
586
|
+
}
|
|
581
587
|
.min-h-40 {
|
|
582
588
|
min-height: calc(var(--spacing) * 40);
|
|
583
589
|
}
|
|
@@ -1041,6 +1047,12 @@
|
|
|
1041
1047
|
background-color: color-mix(in oklab, var(--danger) 10%, transparent);
|
|
1042
1048
|
}
|
|
1043
1049
|
}
|
|
1050
|
+
.bg-danger\/15 {
|
|
1051
|
+
background-color: var(--danger);
|
|
1052
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
1053
|
+
background-color: color-mix(in oklab, var(--danger) 15%, transparent);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1044
1056
|
.bg-deck {
|
|
1045
1057
|
background-color: var(--deck);
|
|
1046
1058
|
}
|
package/mevebu/libs/js/core.js
CHANGED
|
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
|
|
|
750
750
|
// ---------- version ----------
|
|
751
751
|
|
|
752
752
|
// Kept in one place. The CLI reads the same value from package.json.
|
|
753
|
-
export const VERSION = "2.
|
|
753
|
+
export const VERSION = "2.3.0";
|
|
754
754
|
export const PACKAGE_NAME = "mevebu";
|
|
755
755
|
export const REPO_SLUG = "mevebu/mevebu";
|
|
756
756
|
|
|
@@ -481,6 +481,96 @@ export const TOOLS = [
|
|
|
481
481
|
keywords: "aes encrypt decrypt password gcm pbkdf2 secret message",
|
|
482
482
|
load: () => import("./tools/aes-text.js"),
|
|
483
483
|
},
|
|
484
|
+
{
|
|
485
|
+
id: "rsa-key-pair",
|
|
486
|
+
category: "security",
|
|
487
|
+
name: "RSA Key Pair",
|
|
488
|
+
icon: "key-round",
|
|
489
|
+
note: "Make an RSA key pair in PEM form.",
|
|
490
|
+
about: "Generated in this tab with Web Crypto. Public as SPKI, private as PKCS#8.",
|
|
491
|
+
tutorial: {
|
|
492
|
+
intro: "Generate an RSA key pair and copy or download the PEM files.",
|
|
493
|
+
steps: [
|
|
494
|
+
"Pick a key size. 2048 is the common floor.",
|
|
495
|
+
"Press Generate pair and wait a moment for larger keys.",
|
|
496
|
+
"Copy or download the public and private PEM. Closing the page discards them.",
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
keywords: "rsa key pair pem public private ssh jwt tls generate spki pkcs8",
|
|
500
|
+
load: () => import("./tools/rsa-key-pair.js"),
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
id: "bcrypt-hash",
|
|
504
|
+
category: "security",
|
|
505
|
+
name: "Bcrypt Hash",
|
|
506
|
+
icon: "hash",
|
|
507
|
+
note: "Hash a password with bcrypt, and check one.",
|
|
508
|
+
about: "Bcrypt with a cost factor you set, run in this tab with a vendored engine.",
|
|
509
|
+
tutorial: {
|
|
510
|
+
intro: "Make a bcrypt hash for a test login, or check a password against one you already have.",
|
|
511
|
+
steps: [
|
|
512
|
+
"Type a password and pick a cost factor. 10 is a common default.",
|
|
513
|
+
"Press Hash password. The timing shows how long this device takes.",
|
|
514
|
+
"To check, paste a stored hash and the password. The verdict updates as you type.",
|
|
515
|
+
],
|
|
516
|
+
},
|
|
517
|
+
keywords: "bcrypt hash password login test salt cost compare verify",
|
|
518
|
+
load: () => import("./tools/bcrypt-hash.js"),
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
id: "password-policy",
|
|
522
|
+
category: "security",
|
|
523
|
+
name: "Password Policy Builder",
|
|
524
|
+
icon: "list-checks",
|
|
525
|
+
note: "Turn a password rule into text, a regex, and a checklist.",
|
|
526
|
+
about: "The wording, the pattern, and a live check all come from the same settings.",
|
|
527
|
+
tutorial: {
|
|
528
|
+
intro: "Write a password policy once, in every form you need.",
|
|
529
|
+
steps: [
|
|
530
|
+
"Set the length and the character classes.",
|
|
531
|
+
"Read the rule in plain words and copy the regex.",
|
|
532
|
+
"Type a sample password to see which part it fails.",
|
|
533
|
+
],
|
|
534
|
+
},
|
|
535
|
+
keywords: "password policy regex rule requirements checklist enforce",
|
|
536
|
+
load: () => import("./tools/password-policy.js"),
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
id: "secret-scanner",
|
|
540
|
+
category: "security",
|
|
541
|
+
name: "Secret Scanner",
|
|
542
|
+
icon: "shield-alert",
|
|
543
|
+
note: "Find API keys and tokens before you paste them.",
|
|
544
|
+
about: "Known shapes for AWS, GitHub, Slack, Stripe, Google, JWTs and private keys.",
|
|
545
|
+
tutorial: {
|
|
546
|
+
intro: "Catch a leaked key before it goes into a chat.",
|
|
547
|
+
steps: [
|
|
548
|
+
"Paste the text, or drop a file.",
|
|
549
|
+
"Read the matches with their line numbers. Values are masked by default.",
|
|
550
|
+
"Rotate anything listed. A clean result is a hint, not a guarantee.",
|
|
551
|
+
],
|
|
552
|
+
},
|
|
553
|
+
keywords: "secret scanner leak api key token private key aws github slack stripe jwt detect",
|
|
554
|
+
load: () => import("./tools/secret-scanner.js"),
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
id: "ssh-fingerprint",
|
|
558
|
+
category: "security",
|
|
559
|
+
name: "SSH Fingerprint",
|
|
560
|
+
icon: "fingerprint",
|
|
561
|
+
note: "Read the fingerprint of an SSH public key.",
|
|
562
|
+
about: "The SHA-256 form a server prints, plus the older MD5 form for comparison.",
|
|
563
|
+
tutorial: {
|
|
564
|
+
intro: "Compare a host fingerprint before you trust it.",
|
|
565
|
+
steps: [
|
|
566
|
+
"Paste the public key line, for example ssh-ed25519 AAAAC3...",
|
|
567
|
+
"Read the fingerprint and compare it with the one the host shows.",
|
|
568
|
+
"Use the MD5 form only for an old server that still prints that.",
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
keywords: "ssh fingerprint public key sha256 md5 host verify known_hosts",
|
|
572
|
+
load: () => import("./tools/ssh-fingerprint.js"),
|
|
573
|
+
},
|
|
484
574
|
|
|
485
575
|
{
|
|
486
576
|
id: "uuid-generator",
|
|
@@ -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,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 };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// An RSA key pair made in the tab with Web Crypto. The keys exist only in the
|
|
2
|
+
// page until it closes, and both come out as PEM, the form most tools expect.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, selectInput, button, actions,
|
|
5
|
+
toolcard, blank, notice, copyButton, output, checkbox,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
|
|
8
|
+
const SIZES = [
|
|
9
|
+
{ value: "2048", label: "2048 bits" },
|
|
10
|
+
{ value: "3072", label: "3072 bits" },
|
|
11
|
+
{ value: "4096", label: "4096 bits" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function wrap(base64) {
|
|
15
|
+
return base64.match(/.{1,64}/g).join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toPem(label, der) {
|
|
19
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(der)));
|
|
20
|
+
return `-----BEGIN ${label}-----\n${wrap(base64)}\n-----END ${label}-----\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mount(slot) {
|
|
24
|
+
let pair = null;
|
|
25
|
+
let busy = false;
|
|
26
|
+
let token = 0;
|
|
27
|
+
|
|
28
|
+
const size = selectInput(SIZES, { value: "2048", "aria-label": "Key size" });
|
|
29
|
+
const showPrivate = checkbox("Show the private key", { checked: false });
|
|
30
|
+
|
|
31
|
+
const error = notice("warn");
|
|
32
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
33
|
+
const publicOut = output({ rows: 8 });
|
|
34
|
+
const privateOut = output({ rows: 10 });
|
|
35
|
+
const empty = blank("key-round", "Generate a pair to see the keys.");
|
|
36
|
+
const publicBox = el("div", { class: "grid gap-1.5" },
|
|
37
|
+
el("span", { class: "text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase" }, "Public key"), publicOut);
|
|
38
|
+
const privateBox = el("div", { class: "grid gap-1.5" },
|
|
39
|
+
el("span", { class: "text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase" }, "Private key"), privateOut);
|
|
40
|
+
|
|
41
|
+
const generateButton = button("Generate pair", { icon: "refresh-cw", kind: "fill", onclick: generate });
|
|
42
|
+
const copyPublic = copyButton(() => pair?.publicPem ?? "", "Copy public");
|
|
43
|
+
const copyPrivate = copyButton(() => pair?.privatePem ?? "", "Copy private");
|
|
44
|
+
const downloadPublic = button("Download public", {
|
|
45
|
+
icon: "download",
|
|
46
|
+
onclick: () => { if (pair) download("public.pem", pair.publicPem, "application/x-pem-file"); },
|
|
47
|
+
});
|
|
48
|
+
const downloadPrivate = button("Download private", {
|
|
49
|
+
icon: "download",
|
|
50
|
+
onclick: () => { if (pair) download("private.pem", pair.privatePem, "application/x-pem-file"); },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function sync() {
|
|
54
|
+
const hasKeys = Boolean(pair);
|
|
55
|
+
publicBox.hidden = !hasKeys;
|
|
56
|
+
privateBox.hidden = !hasKeys || !showPrivate.input.checked;
|
|
57
|
+
empty.hidden = hasKeys;
|
|
58
|
+
privateOut.hidden = privateBox.hidden;
|
|
59
|
+
copyPrivate.disabled = !hasKeys;
|
|
60
|
+
copyPublic.disabled = !hasKeys;
|
|
61
|
+
downloadPrivate.disabled = !hasKeys;
|
|
62
|
+
downloadPublic.disabled = !hasKeys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function generate() {
|
|
66
|
+
if (busy) return;
|
|
67
|
+
busy = true;
|
|
68
|
+
const current = ++token;
|
|
69
|
+
generateButton.disabled = true;
|
|
70
|
+
summary.textContent = "Generating. Larger keys take a few seconds.";
|
|
71
|
+
error.write("");
|
|
72
|
+
try {
|
|
73
|
+
const keys = await crypto.subtle.generateKey(
|
|
74
|
+
{
|
|
75
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
76
|
+
modulusLength: Number(size.value),
|
|
77
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
78
|
+
hash: "SHA-256",
|
|
79
|
+
},
|
|
80
|
+
true,
|
|
81
|
+
["sign", "verify"]
|
|
82
|
+
);
|
|
83
|
+
const spki = await crypto.subtle.exportKey("spki", keys.publicKey);
|
|
84
|
+
const pkcs8 = await crypto.subtle.exportKey("pkcs8", keys.privateKey);
|
|
85
|
+
if (current !== token) return;
|
|
86
|
+
pair = {
|
|
87
|
+
bits: Number(size.value),
|
|
88
|
+
publicPem: toPem("PUBLIC KEY", spki),
|
|
89
|
+
privatePem: toPem("PRIVATE KEY", pkcs8),
|
|
90
|
+
};
|
|
91
|
+
publicOut.write(pair.publicPem);
|
|
92
|
+
privateOut.write(pair.privatePem);
|
|
93
|
+
summary.textContent = `${pair.bits}-bit RSA · SHA-256 · exponent 65537 · PKCS#8 private, SPKI public`;
|
|
94
|
+
sync();
|
|
95
|
+
toast("Key pair ready");
|
|
96
|
+
} catch (caught) {
|
|
97
|
+
if (current !== token) return;
|
|
98
|
+
error.write(caught?.message || "The browser could not generate this key.");
|
|
99
|
+
} finally {
|
|
100
|
+
busy = false;
|
|
101
|
+
generateButton.disabled = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
size.addEventListener("change", () => {
|
|
106
|
+
if (pair) {
|
|
107
|
+
pair = null;
|
|
108
|
+
publicOut.write("");
|
|
109
|
+
privateOut.write("");
|
|
110
|
+
summary.textContent = "";
|
|
111
|
+
sync();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
showPrivate.input.addEventListener("change", sync);
|
|
115
|
+
|
|
116
|
+
sync();
|
|
117
|
+
|
|
118
|
+
const shell = toolcard({
|
|
119
|
+
body: bench(
|
|
120
|
+
stack(
|
|
121
|
+
panel("Key size", size, "2048 is the common floor. 4096 is slower and rarely needed."),
|
|
122
|
+
showPrivate,
|
|
123
|
+
error,
|
|
124
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
125
|
+
"Generated with the browser's RSA implementation. The keys exist only in this page; closing it discards them. For production, generate keys on a machine you control.")
|
|
126
|
+
),
|
|
127
|
+
stack(summary, empty, publicBox, privateBox)
|
|
128
|
+
),
|
|
129
|
+
foot: actions(downloadPublic, downloadPrivate, copyPublic, copyPrivate, generateButton),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
slot.append(shell.node);
|
|
133
|
+
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default { mount };
|