mevebu 2.0.0 → 2.2.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 +8 -8
- package/mevebu/app/tailwind.css +66 -3
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/random.js +54 -0
- package/mevebu/libs/js/registry.js +180 -0
- package/mevebu/libs/js/tools/aes-text.js +192 -0
- package/mevebu/libs/js/tools/dataset-splitter.js +181 -0
- package/mevebu/libs/js/tools/file-hash.js +154 -0
- package/mevebu/libs/js/tools/json-schema-sample.js +254 -0
- package/mevebu/libs/js/tools/mock-api-payload.js +224 -0
- package/mevebu/libs/js/tools/passphrase-generator.js +149 -0
- package/mevebu/libs/js/tools/sample-csv.js +2 -36
- package/mevebu/libs/js/tools/survey-responses.js +225 -0
- package/mevebu/libs/js/tools/time-series.js +191 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# mevebu
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**210 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 **210 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 **210 tools in 20 categories**. Here is the full breakdown:
|
|
68
68
|
|
|
69
69
|
| Category | Tools |
|
|
70
70
|
| ------------------ | ----: |
|
|
71
|
-
| Data Generator |
|
|
72
|
-
| Passwords and keys |
|
|
71
|
+
| Data Generator | 10 |
|
|
72
|
+
| Passwords and keys | 15 |
|
|
73
73
|
| Developer | 21 |
|
|
74
74
|
| Convert | 15 |
|
|
75
75
|
| Text and data | 14 |
|
|
@@ -88,7 +88,7 @@ mevebu has **200 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** | **210** |
|
|
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 210 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
|
@@ -37,9 +37,6 @@
|
|
|
37
37
|
--text-lead--line-height: 1.06;
|
|
38
38
|
--text-hero: clamp(34px, 6vw, 58px);
|
|
39
39
|
--text-hero--line-height: 1.03;
|
|
40
|
-
--text-display: 44px;
|
|
41
|
-
--text-display--line-height: 1;
|
|
42
|
-
--animate-toss: toss 450ms ease;
|
|
43
40
|
}
|
|
44
41
|
}
|
|
45
42
|
@layer base {
|
|
@@ -503,6 +500,9 @@
|
|
|
503
500
|
width: var(--ctl);
|
|
504
501
|
height: var(--ctl);
|
|
505
502
|
}
|
|
503
|
+
.h-1\.5 {
|
|
504
|
+
height: calc(var(--spacing) * 1.5);
|
|
505
|
+
}
|
|
506
506
|
.h-2 {
|
|
507
507
|
height: calc(var(--spacing) * 2);
|
|
508
508
|
}
|
|
@@ -512,6 +512,9 @@
|
|
|
512
512
|
.h-24 {
|
|
513
513
|
height: calc(var(--spacing) * 24);
|
|
514
514
|
}
|
|
515
|
+
.h-40 {
|
|
516
|
+
height: calc(var(--spacing) * 40);
|
|
517
|
+
}
|
|
515
518
|
.h-\[22px\] {
|
|
516
519
|
height: 22px;
|
|
517
520
|
}
|
|
@@ -539,6 +542,9 @@
|
|
|
539
542
|
.h-px {
|
|
540
543
|
height: 1px;
|
|
541
544
|
}
|
|
545
|
+
.max-h-56 {
|
|
546
|
+
max-height: calc(var(--spacing) * 56);
|
|
547
|
+
}
|
|
542
548
|
.max-h-80 {
|
|
543
549
|
max-height: calc(var(--spacing) * 80);
|
|
544
550
|
}
|
|
@@ -563,6 +569,9 @@
|
|
|
563
569
|
.min-h-8 {
|
|
564
570
|
min-height: calc(var(--spacing) * 8);
|
|
565
571
|
}
|
|
572
|
+
.min-h-9 {
|
|
573
|
+
min-height: calc(var(--spacing) * 9);
|
|
574
|
+
}
|
|
566
575
|
.min-h-11 {
|
|
567
576
|
min-height: calc(var(--spacing) * 11);
|
|
568
577
|
}
|
|
@@ -773,6 +782,9 @@
|
|
|
773
782
|
.grid-cols-2 {
|
|
774
783
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
775
784
|
}
|
|
785
|
+
.grid-cols-3 {
|
|
786
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
787
|
+
}
|
|
776
788
|
.grid-cols-4 {
|
|
777
789
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
778
790
|
}
|
|
@@ -978,9 +990,21 @@
|
|
|
978
990
|
--tw-border-style: dashed;
|
|
979
991
|
border-style: dashed;
|
|
980
992
|
}
|
|
993
|
+
.border-current\/15 {
|
|
994
|
+
border-color: currentcolor;
|
|
995
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
996
|
+
border-color: color-mix(in oklab, currentcolor 15%, transparent);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
981
999
|
.border-danger {
|
|
982
1000
|
border-color: var(--danger);
|
|
983
1001
|
}
|
|
1002
|
+
.border-danger\/40 {
|
|
1003
|
+
border-color: var(--danger);
|
|
1004
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
1005
|
+
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
984
1008
|
.border-jade {
|
|
985
1009
|
border-color: var(--jade);
|
|
986
1010
|
}
|
|
@@ -1011,6 +1035,12 @@
|
|
|
1011
1035
|
.bg-danger {
|
|
1012
1036
|
background-color: var(--danger);
|
|
1013
1037
|
}
|
|
1038
|
+
.bg-danger\/10 {
|
|
1039
|
+
background-color: var(--danger);
|
|
1040
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
1041
|
+
background-color: color-mix(in oklab, var(--danger) 10%, transparent);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1014
1044
|
.bg-deck {
|
|
1015
1045
|
background-color: var(--deck);
|
|
1016
1046
|
}
|
|
@@ -1361,9 +1391,19 @@
|
|
|
1361
1391
|
.text-\[0\.92em\] {
|
|
1362
1392
|
font-size: 0.92em;
|
|
1363
1393
|
}
|
|
1394
|
+
.text-\[clamp\(20px\,3\.4vw\,28px\)\] {
|
|
1395
|
+
font-size: clamp(20px, 3.4vw, 28px);
|
|
1396
|
+
}
|
|
1364
1397
|
.text-\[clamp\(34px\,6vw\,52px\)\] {
|
|
1365
1398
|
font-size: clamp(34px, 6vw, 52px);
|
|
1366
1399
|
}
|
|
1400
|
+
.text-\[clamp\(38px\,9vw\,62px\)\] {
|
|
1401
|
+
font-size: clamp(38px, 9vw, 62px);
|
|
1402
|
+
}
|
|
1403
|
+
.leading-\[1\.4\] {
|
|
1404
|
+
--tw-leading: 1.4;
|
|
1405
|
+
line-height: 1.4;
|
|
1406
|
+
}
|
|
1367
1407
|
.leading-\[1\.5\] {
|
|
1368
1408
|
--tw-leading: 1.5;
|
|
1369
1409
|
line-height: 1.5;
|
|
@@ -1384,6 +1424,10 @@
|
|
|
1384
1424
|
--tw-leading: 1.65;
|
|
1385
1425
|
line-height: 1.65;
|
|
1386
1426
|
}
|
|
1427
|
+
.leading-none {
|
|
1428
|
+
--tw-leading: 1;
|
|
1429
|
+
line-height: 1;
|
|
1430
|
+
}
|
|
1387
1431
|
.leading-normal {
|
|
1388
1432
|
--tw-leading: var(--leading-normal);
|
|
1389
1433
|
line-height: var(--leading-normal);
|
|
@@ -1420,6 +1464,10 @@
|
|
|
1420
1464
|
--tw-tracking: -0.035em;
|
|
1421
1465
|
letter-spacing: -0.035em;
|
|
1422
1466
|
}
|
|
1467
|
+
.tracking-\[0\.2em\] {
|
|
1468
|
+
--tw-tracking: 0.2em;
|
|
1469
|
+
letter-spacing: 0.2em;
|
|
1470
|
+
}
|
|
1423
1471
|
.tracking-\[0\.06em\] {
|
|
1424
1472
|
--tw-tracking: 0.06em;
|
|
1425
1473
|
letter-spacing: 0.06em;
|
|
@@ -1428,6 +1476,10 @@
|
|
|
1428
1476
|
--tw-tracking: 0.08em;
|
|
1429
1477
|
letter-spacing: 0.08em;
|
|
1430
1478
|
}
|
|
1479
|
+
.tracking-\[0\.14em\] {
|
|
1480
|
+
--tw-tracking: 0.14em;
|
|
1481
|
+
letter-spacing: 0.14em;
|
|
1482
|
+
}
|
|
1431
1483
|
.text-balance {
|
|
1432
1484
|
text-wrap: balance;
|
|
1433
1485
|
}
|
|
@@ -1476,6 +1528,9 @@
|
|
|
1476
1528
|
.text-void {
|
|
1477
1529
|
color: var(--void);
|
|
1478
1530
|
}
|
|
1531
|
+
.capitalize {
|
|
1532
|
+
text-transform: capitalize;
|
|
1533
|
+
}
|
|
1479
1534
|
.lowercase {
|
|
1480
1535
|
text-transform: lowercase;
|
|
1481
1536
|
}
|
|
@@ -1626,10 +1681,18 @@
|
|
|
1626
1681
|
--tw-duration: 200ms;
|
|
1627
1682
|
transition-duration: 200ms;
|
|
1628
1683
|
}
|
|
1684
|
+
.duration-1000 {
|
|
1685
|
+
--tw-duration: 1000ms;
|
|
1686
|
+
transition-duration: 1000ms;
|
|
1687
|
+
}
|
|
1629
1688
|
.ease-\[var\(--ease\)\] {
|
|
1630
1689
|
--tw-ease: var(--ease);
|
|
1631
1690
|
transition-timing-function: var(--ease);
|
|
1632
1691
|
}
|
|
1692
|
+
.ease-linear {
|
|
1693
|
+
--tw-ease: linear;
|
|
1694
|
+
transition-timing-function: linear;
|
|
1695
|
+
}
|
|
1633
1696
|
.outline-none {
|
|
1634
1697
|
--tw-outline-style: none;
|
|
1635
1698
|
outline-style: none;
|
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.2.0";
|
|
754
754
|
export const PACKAGE_NAME = "mevebu";
|
|
755
755
|
export const REPO_SLUG = "mevebu/mevebu";
|
|
756
756
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A small seeded generator shared by the data tools. A seed is worth writing
|
|
2
|
+
// down: the same one always produces the same file, so a fixture can be rebuilt
|
|
3
|
+
// after it drifts.
|
|
4
|
+
|
|
5
|
+
/** Mulberry32. Fast, deterministic, and good enough for fixtures. */
|
|
6
|
+
export function seeded(seed) {
|
|
7
|
+
let state = hashSeed(String(seed)) >>> 0;
|
|
8
|
+
return () => {
|
|
9
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
10
|
+
let t = state;
|
|
11
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
12
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
13
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hashSeed(text) {
|
|
18
|
+
let hash = 2166136261;
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
hash ^= text.charCodeAt(i);
|
|
21
|
+
hash = Math.imul(hash, 16777619);
|
|
22
|
+
}
|
|
23
|
+
return hash >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pick(random, list) {
|
|
27
|
+
return list[Math.floor(random() * list.length)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shuffle(list, random) {
|
|
31
|
+
const out = [...list];
|
|
32
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
33
|
+
const j = Math.floor(random() * (i + 1));
|
|
34
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A number from a normal distribution, by Box-Muller. */
|
|
40
|
+
export function gaussian(random, mean = 0, deviation = 1) {
|
|
41
|
+
const u = Math.max(1e-9, random());
|
|
42
|
+
const v = random();
|
|
43
|
+
return mean + deviation * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function uuid(random) {
|
|
47
|
+
const hex = "0123456789abcdef";
|
|
48
|
+
let out = "";
|
|
49
|
+
for (let i = 0; i < 32; i++) {
|
|
50
|
+
if (i === 8 || i === 12 || i === 16 || i === 20) out += "-";
|
|
51
|
+
out += i === 12 ? "4" : hex[Math.floor(random() * 16)];
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
@@ -120,6 +120,96 @@ export const TOOLS = [
|
|
|
120
120
|
keywords: "data dictionary csv schema profile columns nulls distinct metadata",
|
|
121
121
|
load: () => import("./tools/data-dictionary.js"),
|
|
122
122
|
},
|
|
123
|
+
{
|
|
124
|
+
id: "json-schema-sample",
|
|
125
|
+
category: "data-gen",
|
|
126
|
+
name: "JSON Schema Sample",
|
|
127
|
+
icon: "braces",
|
|
128
|
+
note: "Generate documents from a JSON Schema.",
|
|
129
|
+
about: "Required fields, types, enums, bounds and patterns, with a seed.",
|
|
130
|
+
tutorial: {
|
|
131
|
+
intro: "Make sample documents that pass a schema.",
|
|
132
|
+
steps: [
|
|
133
|
+
"Paste a JSON Schema.",
|
|
134
|
+
"Set how many documents and a seed.",
|
|
135
|
+
"Press Generate, then copy the JSON or take a ZIP with one file per document.",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
keywords: "json schema sample mock fixture generate validator draft 2020-12",
|
|
139
|
+
load: () => import("./tools/json-schema-sample.js"),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "mock-api-payload",
|
|
143
|
+
category: "data-gen",
|
|
144
|
+
name: "Mock API Payload",
|
|
145
|
+
icon: "braces",
|
|
146
|
+
note: "Write the JSON an endpoint returns.",
|
|
147
|
+
about: "A list with pagination, a single item, or an error, from one field list.",
|
|
148
|
+
tutorial: {
|
|
149
|
+
intro: "Build a fixture before the endpoint exists.",
|
|
150
|
+
steps: [
|
|
151
|
+
"Define the fields and their types.",
|
|
152
|
+
"Pick the shape: list, single item, or error.",
|
|
153
|
+
"Copy the JSON or download it.",
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
keywords: "mock api payload fixture json response pagination error frontend",
|
|
157
|
+
load: () => import("./tools/mock-api-payload.js"),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "dataset-splitter",
|
|
161
|
+
category: "data-gen",
|
|
162
|
+
name: "Dataset Splitter",
|
|
163
|
+
icon: "table",
|
|
164
|
+
note: "Split a CSV into train, validation and test.",
|
|
165
|
+
about: "Seeded and stratified, and every row lands in exactly one set.",
|
|
166
|
+
tutorial: {
|
|
167
|
+
intro: "Prepare a dataset for training.",
|
|
168
|
+
steps: [
|
|
169
|
+
"Drop a CSV.",
|
|
170
|
+
"Set the train and validation percentages, a seed, and an optional stratify column.",
|
|
171
|
+
"Press Split and download for a ZIP with three files.",
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
keywords: "dataset split train validation test machine learning csv stratified seed",
|
|
175
|
+
load: () => import("./tools/dataset-splitter.js"),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "time-series",
|
|
179
|
+
category: "data-gen",
|
|
180
|
+
name: "Synthetic Time Series",
|
|
181
|
+
icon: "chart-line",
|
|
182
|
+
note: "Generate a dated series with trend and season.",
|
|
183
|
+
about: "Preview the chart, add a spike, then download the CSV.",
|
|
184
|
+
tutorial: {
|
|
185
|
+
intro: "Make a series that looks like real data.",
|
|
186
|
+
steps: [
|
|
187
|
+
"Set the start date, the frequency and the length.",
|
|
188
|
+
"Shape the trend, the season and the noise, then add a spike if you need one.",
|
|
189
|
+
"Press Download CSV.",
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
keywords: "time series synthetic data trend season noise chart demo seed",
|
|
193
|
+
load: () => import("./tools/time-series.js"),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "survey-responses",
|
|
197
|
+
category: "data-gen",
|
|
198
|
+
name: "Survey Responses",
|
|
199
|
+
icon: "clipboard-list",
|
|
200
|
+
note: "Generate fake survey answers.",
|
|
201
|
+
about: "Weights per option, so answers lean the way real answers lean.",
|
|
202
|
+
tutorial: {
|
|
203
|
+
intro: "Test a results dashboard with plausible responses.",
|
|
204
|
+
steps: [
|
|
205
|
+
"Add questions and their options.",
|
|
206
|
+
"Set a weight per option and the number of respondents.",
|
|
207
|
+
"Press Download CSV. One row is one respondent.",
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
keywords: "survey responses fake data dashboard test weights csv",
|
|
211
|
+
load: () => import("./tools/survey-responses.js"),
|
|
212
|
+
},
|
|
123
213
|
|
|
124
214
|
{
|
|
125
215
|
id: "password-generator",
|
|
@@ -301,6 +391,96 @@ export const TOOLS = [
|
|
|
301
391
|
keywords: "url inspect phishing host homograph link safe",
|
|
302
392
|
load: () => import("./tools/url-inspector.js"),
|
|
303
393
|
},
|
|
394
|
+
{
|
|
395
|
+
id: "totp-generator",
|
|
396
|
+
category: "security",
|
|
397
|
+
name: "TOTP Generator",
|
|
398
|
+
icon: "shield-check",
|
|
399
|
+
note: "Read the code your authenticator would show.",
|
|
400
|
+
about: "Paste a Base32 secret or an otpauth URI. The code refreshes on its own.",
|
|
401
|
+
tutorial: {
|
|
402
|
+
intro: "Get a time based one time password from a secret.",
|
|
403
|
+
steps: [
|
|
404
|
+
"Paste a Base32 secret, or a full otpauth:// URI.",
|
|
405
|
+
"Pick the digits, the period and the hash if they are not the default.",
|
|
406
|
+
"Copy the code. It changes on its own every period.",
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
keywords: "totp otp 2fa authenticator google authenticator base32 code",
|
|
410
|
+
load: () => import("./tools/totp-generator.js"),
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
id: "totp-verifier",
|
|
414
|
+
category: "security",
|
|
415
|
+
name: "TOTP Verifier",
|
|
416
|
+
icon: "shield-check",
|
|
417
|
+
note: "Check a code against a secret.",
|
|
418
|
+
about: "Shows which step matched, so a wrong secret is distinguishable from a wrong clock.",
|
|
419
|
+
tutorial: {
|
|
420
|
+
intro: "Debug a two factor integration.",
|
|
421
|
+
steps: [
|
|
422
|
+
"Paste the secret and the code.",
|
|
423
|
+
"Set the drift window, or leave it at one step either side.",
|
|
424
|
+
"Read the verdict, which names the step that matched.",
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
keywords: "totp verify validate otp 2fa drift clock debug",
|
|
428
|
+
load: () => import("./tools/totp-verifier.js"),
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: "passphrase-generator",
|
|
432
|
+
category: "security",
|
|
433
|
+
name: "Passphrase Generator",
|
|
434
|
+
icon: "key-round",
|
|
435
|
+
note: "Make a passphrase you can type from memory.",
|
|
436
|
+
about: "Words from a bundled list, with the entropy in bits shown next to it.",
|
|
437
|
+
tutorial: {
|
|
438
|
+
intro: "Make a password that is long and memorable.",
|
|
439
|
+
steps: [
|
|
440
|
+
"Pick how many words.",
|
|
441
|
+
"Choose a separator, and whether to add a number or a symbol.",
|
|
442
|
+
"Press Generate, then copy it.",
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
keywords: "passphrase password memorable words diceware entropy",
|
|
446
|
+
load: () => import("./tools/passphrase-generator.js"),
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: "file-hash",
|
|
450
|
+
category: "security",
|
|
451
|
+
name: "File Hash Checker",
|
|
452
|
+
icon: "file-key",
|
|
453
|
+
note: "Hash a file and compare the checksum.",
|
|
454
|
+
about: "SHA-1, SHA-256 and SHA-512, read in the tab, with a match or mismatch.",
|
|
455
|
+
tutorial: {
|
|
456
|
+
intro: "Verify a download before you open it.",
|
|
457
|
+
steps: [
|
|
458
|
+
"Drop the file.",
|
|
459
|
+
"Paste the checksum you were given.",
|
|
460
|
+
"Read match or mismatch, and copy any digest you need.",
|
|
461
|
+
],
|
|
462
|
+
},
|
|
463
|
+
keywords: "file hash checksum sha256 verify download integrity",
|
|
464
|
+
load: () => import("./tools/file-hash.js"),
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
id: "aes-text",
|
|
468
|
+
category: "security",
|
|
469
|
+
name: "AES Text Encrypt",
|
|
470
|
+
icon: "lock",
|
|
471
|
+
note: "Encrypt a message with a password.",
|
|
472
|
+
about: "AES-256-GCM with PBKDF2, and one string that carries everything needed to decrypt.",
|
|
473
|
+
tutorial: {
|
|
474
|
+
intro: "Send a secret through a channel you do not trust.",
|
|
475
|
+
steps: [
|
|
476
|
+
"Write the message and set a password.",
|
|
477
|
+
"Press Encrypt and copy the string that starts with mv1.",
|
|
478
|
+
"To read it back, switch to Decrypt, paste the string and the same password.",
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
keywords: "aes encrypt decrypt password gcm pbkdf2 secret message",
|
|
482
|
+
load: () => import("./tools/aes-text.js"),
|
|
483
|
+
},
|
|
304
484
|
|
|
305
485
|
{
|
|
306
486
|
id: "uuid-generator",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Encrypt a message with a password. AES-256-GCM with a PBKDF2 key, and the salt
|
|
2
|
+
// and IV packed into the output, so one string is enough to decrypt.
|
|
3
|
+
import {
|
|
4
|
+
el, icon, toast, bench, stack, panel, textArea, textInput, button, actions,
|
|
5
|
+
toolcard, notice, copyButton, output, chipRow,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
|
|
8
|
+
const ITERATIONS = 310_000;
|
|
9
|
+
const SALT_BYTES = 16;
|
|
10
|
+
const IV_BYTES = 12;
|
|
11
|
+
const VERSION = 1;
|
|
12
|
+
|
|
13
|
+
function toBase64(bytes) {
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fromBase64(text) {
|
|
20
|
+
const binary = atob(text.replace(/\s+/g, ""));
|
|
21
|
+
const out = new Uint8Array(binary.length);
|
|
22
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function deriveKey(password, salt) {
|
|
27
|
+
const base = await crypto.subtle.importKey(
|
|
28
|
+
"raw",
|
|
29
|
+
new TextEncoder().encode(password),
|
|
30
|
+
"PBKDF2",
|
|
31
|
+
false,
|
|
32
|
+
["deriveKey"]
|
|
33
|
+
);
|
|
34
|
+
return crypto.subtle.deriveKey(
|
|
35
|
+
{ name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" },
|
|
36
|
+
base,
|
|
37
|
+
{ name: "AES-GCM", length: 256 },
|
|
38
|
+
false,
|
|
39
|
+
["encrypt", "decrypt"]
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mount(slot) {
|
|
44
|
+
let mode = "encrypt";
|
|
45
|
+
|
|
46
|
+
const message = textArea({ rows: 6, placeholder: "The message", "aria-label": "Message" });
|
|
47
|
+
const password = textInput({
|
|
48
|
+
type: "password",
|
|
49
|
+
placeholder: "A password you will remember",
|
|
50
|
+
"aria-label": "Password",
|
|
51
|
+
autocomplete: "new-password",
|
|
52
|
+
});
|
|
53
|
+
const show = el("input", { type: "checkbox", class: "m-0 size-4 accent-[var(--jade)]" });
|
|
54
|
+
const showLabel = el("label", { class: "inline-flex min-h-9 cursor-pointer items-center gap-2 text-sm text-ink-2" }, show, "Show password");
|
|
55
|
+
|
|
56
|
+
const modeRow = chipRow(
|
|
57
|
+
[
|
|
58
|
+
{ value: "encrypt", label: "Encrypt" },
|
|
59
|
+
{ value: "decrypt", label: "Decrypt" },
|
|
60
|
+
],
|
|
61
|
+
{
|
|
62
|
+
value: "encrypt",
|
|
63
|
+
label: "Mode",
|
|
64
|
+
onChange: (value) => {
|
|
65
|
+
mode = value;
|
|
66
|
+
sync();
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const error = notice("warn");
|
|
72
|
+
const result = output({ rows: 8 });
|
|
73
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
74
|
+
|
|
75
|
+
function sync() {
|
|
76
|
+
const decrypting = mode === "decrypt";
|
|
77
|
+
message.placeholder = decrypting
|
|
78
|
+
? "Paste the encrypted text, starting with mv1."
|
|
79
|
+
: "The message";
|
|
80
|
+
message.setAttribute("aria-label", decrypting ? "Encrypted text" : "Message");
|
|
81
|
+
run.textContent = decrypting ? "Decrypt text" : "Encrypt text";
|
|
82
|
+
result.write("");
|
|
83
|
+
summary.textContent = "";
|
|
84
|
+
error.write("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function act() {
|
|
88
|
+
error.write("");
|
|
89
|
+
result.write("");
|
|
90
|
+
summary.textContent = "";
|
|
91
|
+
const text = message.value;
|
|
92
|
+
const pass = password.value;
|
|
93
|
+
if (!text.trim()) {
|
|
94
|
+
error.write(mode === "decrypt" ? "Paste the encrypted text first." : "Write the message first.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!pass) {
|
|
98
|
+
error.write("Set a password first.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (mode === "encrypt") {
|
|
104
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
105
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
106
|
+
const key = await deriveKey(pass, salt);
|
|
107
|
+
const cipher = new Uint8Array(
|
|
108
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text))
|
|
109
|
+
);
|
|
110
|
+
const packed = new Uint8Array(1 + SALT_BYTES + IV_BYTES + cipher.length);
|
|
111
|
+
packed[0] = VERSION;
|
|
112
|
+
packed.set(salt, 1);
|
|
113
|
+
packed.set(iv, 1 + SALT_BYTES);
|
|
114
|
+
packed.set(cipher, 1 + SALT_BYTES + IV_BYTES);
|
|
115
|
+
const encoded = `mv1.${toBase64(packed)}`;
|
|
116
|
+
result.write(encoded);
|
|
117
|
+
summary.textContent = `${encoded.length} characters · AES-256-GCM · PBKDF2-SHA-256, ${ITERATIONS.toLocaleString()} rounds`;
|
|
118
|
+
toast("Encrypted");
|
|
119
|
+
} else {
|
|
120
|
+
const clean = text.trim();
|
|
121
|
+
const body = clean.startsWith("mv1.") ? clean.slice(4) : clean;
|
|
122
|
+
let packed;
|
|
123
|
+
try {
|
|
124
|
+
packed = fromBase64(body);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error("That is not Base64. Paste the whole string, starting with mv1.");
|
|
127
|
+
}
|
|
128
|
+
if (packed.length < 1 + SALT_BYTES + IV_BYTES + 16) throw new Error("That string is too short to be an encrypted message.");
|
|
129
|
+
if (packed[0] !== VERSION) throw new Error(`This was written by version ${packed[0]}, which this page does not know.`);
|
|
130
|
+
const salt = packed.slice(1, 1 + SALT_BYTES);
|
|
131
|
+
const iv = packed.slice(1 + SALT_BYTES, 1 + SALT_BYTES + IV_BYTES);
|
|
132
|
+
const cipher = packed.slice(1 + SALT_BYTES + IV_BYTES);
|
|
133
|
+
const key = await deriveKey(pass, salt);
|
|
134
|
+
let plain;
|
|
135
|
+
try {
|
|
136
|
+
plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new Error("Wrong password, or the text was changed after it was encrypted.");
|
|
139
|
+
}
|
|
140
|
+
const decoded = new TextDecoder().decode(plain);
|
|
141
|
+
result.write(decoded);
|
|
142
|
+
summary.textContent = `${decoded.length} characters`;
|
|
143
|
+
toast("Decrypted");
|
|
144
|
+
}
|
|
145
|
+
} catch (caught) {
|
|
146
|
+
error.write(caught?.message || "That did not work.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
show.addEventListener("change", () => {
|
|
151
|
+
password.type = show.checked ? "text" : "password";
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const run = button("Encrypt text", { icon: "lock", kind: "fill", onclick: act });
|
|
155
|
+
const copy = copyButton(() => result.textContent, "Copy result");
|
|
156
|
+
const clear = button("Clear", {
|
|
157
|
+
icon: "trash",
|
|
158
|
+
kind: "quiet",
|
|
159
|
+
onclick: () => {
|
|
160
|
+
message.value = "";
|
|
161
|
+
password.value = "";
|
|
162
|
+
result.write("");
|
|
163
|
+
summary.textContent = "";
|
|
164
|
+
error.write("");
|
|
165
|
+
message.focus();
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
sync();
|
|
170
|
+
|
|
171
|
+
const shell = toolcard({
|
|
172
|
+
body: bench(
|
|
173
|
+
stack(
|
|
174
|
+
el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Mode"), modeRow),
|
|
175
|
+
panel("Message", message),
|
|
176
|
+
panel("Password", password),
|
|
177
|
+
showLabel,
|
|
178
|
+
error
|
|
179
|
+
),
|
|
180
|
+
stack(summary, result,
|
|
181
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
182
|
+
"AES-256-GCM with a key stretched by PBKDF2-SHA-256. The salt and IV are packed into the output, so one string is all you need to decrypt. A lost password cannot be recovered, which is the point."))
|
|
183
|
+
),
|
|
184
|
+
foot: actions(clear, copy, run),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
slot.append(shell.node);
|
|
188
|
+
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default { mount };
|