mevebu 2.1.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mevebu
4
4
 
5
- **205 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
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
  [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
11
  [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
12
  [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
- ![Tools](https://img.shields.io/badge/tools-205-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-210-brightgreen)
14
14
  ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
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 **205 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.
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 **205 tools in 20 categories**. Here is the full breakdown:
67
+ mevebu has **210 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 | 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 **205 tools in 20 categories**. Here is the full breakdown:
88
88
  | GitHub | 8 |
89
89
  | Units | 8 |
90
90
  | QR codes | 2 |
91
- | **Total** | **205** |
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 205 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
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.
@@ -500,6 +500,9 @@
500
500
  width: var(--ctl);
501
501
  height: var(--ctl);
502
502
  }
503
+ .h-1\.5 {
504
+ height: calc(var(--spacing) * 1.5);
505
+ }
503
506
  .h-2 {
504
507
  height: calc(var(--spacing) * 2);
505
508
  }
@@ -566,6 +569,9 @@
566
569
  .min-h-8 {
567
570
  min-height: calc(var(--spacing) * 8);
568
571
  }
572
+ .min-h-9 {
573
+ min-height: calc(var(--spacing) * 9);
574
+ }
569
575
  .min-h-11 {
570
576
  min-height: calc(var(--spacing) * 11);
571
577
  }
@@ -984,9 +990,21 @@
984
990
  --tw-border-style: dashed;
985
991
  border-style: dashed;
986
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
+ }
987
999
  .border-danger {
988
1000
  border-color: var(--danger);
989
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
+ }
990
1008
  .border-jade {
991
1009
  border-color: var(--jade);
992
1010
  }
@@ -1017,6 +1035,12 @@
1017
1035
  .bg-danger {
1018
1036
  background-color: var(--danger);
1019
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
+ }
1020
1044
  .bg-deck {
1021
1045
  background-color: var(--deck);
1022
1046
  }
@@ -1367,9 +1391,19 @@
1367
1391
  .text-\[0\.92em\] {
1368
1392
  font-size: 0.92em;
1369
1393
  }
1394
+ .text-\[clamp\(20px\,3\.4vw\,28px\)\] {
1395
+ font-size: clamp(20px, 3.4vw, 28px);
1396
+ }
1370
1397
  .text-\[clamp\(34px\,6vw\,52px\)\] {
1371
1398
  font-size: clamp(34px, 6vw, 52px);
1372
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
+ }
1373
1407
  .leading-\[1\.5\] {
1374
1408
  --tw-leading: 1.5;
1375
1409
  line-height: 1.5;
@@ -1390,6 +1424,10 @@
1390
1424
  --tw-leading: 1.65;
1391
1425
  line-height: 1.65;
1392
1426
  }
1427
+ .leading-none {
1428
+ --tw-leading: 1;
1429
+ line-height: 1;
1430
+ }
1393
1431
  .leading-normal {
1394
1432
  --tw-leading: var(--leading-normal);
1395
1433
  line-height: var(--leading-normal);
@@ -1426,6 +1464,10 @@
1426
1464
  --tw-tracking: -0.035em;
1427
1465
  letter-spacing: -0.035em;
1428
1466
  }
1467
+ .tracking-\[0\.2em\] {
1468
+ --tw-tracking: 0.2em;
1469
+ letter-spacing: 0.2em;
1470
+ }
1429
1471
  .tracking-\[0\.06em\] {
1430
1472
  --tw-tracking: 0.06em;
1431
1473
  letter-spacing: 0.06em;
@@ -1434,6 +1476,10 @@
1434
1476
  --tw-tracking: 0.08em;
1435
1477
  letter-spacing: 0.08em;
1436
1478
  }
1479
+ .tracking-\[0\.14em\] {
1480
+ --tw-tracking: 0.14em;
1481
+ letter-spacing: 0.14em;
1482
+ }
1437
1483
  .text-balance {
1438
1484
  text-wrap: balance;
1439
1485
  }
@@ -1482,6 +1528,9 @@
1482
1528
  .text-void {
1483
1529
  color: var(--void);
1484
1530
  }
1531
+ .capitalize {
1532
+ text-transform: capitalize;
1533
+ }
1485
1534
  .lowercase {
1486
1535
  text-transform: lowercase;
1487
1536
  }
@@ -1632,10 +1681,18 @@
1632
1681
  --tw-duration: 200ms;
1633
1682
  transition-duration: 200ms;
1634
1683
  }
1684
+ .duration-1000 {
1685
+ --tw-duration: 1000ms;
1686
+ transition-duration: 1000ms;
1687
+ }
1635
1688
  .ease-\[var\(--ease\)\] {
1636
1689
  --tw-ease: var(--ease);
1637
1690
  transition-timing-function: var(--ease);
1638
1691
  }
1692
+ .ease-linear {
1693
+ --tw-ease: linear;
1694
+ transition-timing-function: linear;
1695
+ }
1639
1696
  .outline-none {
1640
1697
  --tw-outline-style: none;
1641
1698
  outline-style: none;
@@ -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.1.0";
753
+ export const VERSION = "2.2.0";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -391,6 +391,96 @@ export const TOOLS = [
391
391
  keywords: "url inspect phishing host homograph link safe",
392
392
  load: () => import("./tools/url-inspector.js"),
393
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
+ },
394
484
 
395
485
  {
396
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 };
@@ -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,178 @@
1
+ // The code an authenticator app would show, from a secret you paste. The HMAC
2
+ // runs here, so a real secret never has to be typed into a website.
3
+ import {
4
+ el, icon, toast, bench, stack, panel, textInput, selectInput, numberInput,
5
+ button, actions, toolcard, notice, copyButton, checkbox,
6
+ } from "../core.js";
7
+ import { base32Decode, parseOtpUri, totp, stepProgress, formatSecret, randomSecret } from "../totp.js";
8
+
9
+ export function mount(slot) {
10
+ let secretBytes = null;
11
+ let timer = null;
12
+ let current = "";
13
+
14
+ const secret = textInput({
15
+ placeholder: "JBSWY3DPEHPK3PXP or otpauth://totp/...",
16
+ "aria-label": "Secret",
17
+ class: "font-mono",
18
+ autocomplete: "off",
19
+ spellcheck: "false",
20
+ });
21
+ const digits = selectInput(
22
+ [
23
+ { value: "6", label: "6 digits" },
24
+ { value: "8", label: "8 digits" },
25
+ ],
26
+ { value: "6", "aria-label": "Digits" }
27
+ );
28
+ const period = numberInput({ min: "5", max: "300", step: "1", value: "30", "aria-label": "Period in seconds" });
29
+ const algorithm = selectInput(
30
+ [
31
+ { value: "SHA1", label: "SHA-1" },
32
+ { value: "SHA256", label: "SHA-256" },
33
+ { value: "SHA512", label: "SHA-512" },
34
+ ],
35
+ { value: "SHA1", "aria-label": "Algorithm" }
36
+ );
37
+ const showSecret = checkbox("Show the secret", { checked: false });
38
+
39
+ const error = notice("warn");
40
+ const code = el("p", {
41
+ class: "m-0 font-mono text-[clamp(38px,9vw,62px)] font-bold leading-none tracking-[0.14em] text-ink tabular-nums",
42
+ "aria-live": "polite",
43
+ }, "------");
44
+ const meta = el("p", { class: "m-0 text-sm text-ink-2" }, "Paste a secret to start.");
45
+ const ring = el("div", { class: "h-1.5 overflow-hidden rounded-full bg-deck-2" },
46
+ el("div", { class: "h-full w-full rounded-full bg-jade transition-[width] duration-1000 ease-linear" }));
47
+ const panelBody = el("div", { class: "grid gap-3 rounded-r2 border border-orbit bg-deck-2 p-4" }, code, meta, ring);
48
+
49
+ function stop() {
50
+ clearInterval(timer);
51
+ timer = null;
52
+ }
53
+
54
+ function readSecret() {
55
+ const raw = secret.value.trim();
56
+ if (!raw) return null;
57
+ const uri = parseOtpUri(raw);
58
+ if (uri) {
59
+ if (uri.type !== "totp") throw new Error("That URI is for a different kind of token, not TOTP.");
60
+ if (!uri.secret) throw new Error("That otpauth URI has no secret in it.");
61
+ if (uri.digits) digits.value = String(uri.digits === 8 ? 8 : 6);
62
+ if (uri.period) period.value = String(uri.period);
63
+ if (uri.algorithm) algorithm.value = ["SHA1", "SHA256", "SHA512"].includes(uri.algorithm) ? uri.algorithm : "SHA1";
64
+ return { bytes: base32Decode(uri.secret), label: uri.issuer || uri.label };
65
+ }
66
+ return { bytes: base32Decode(raw), label: "" };
67
+ }
68
+
69
+ async function tick() {
70
+ if (!secretBytes) return;
71
+ const options = {
72
+ digits: Number(digits.value) || 6,
73
+ period: Math.max(5, Number(period.value) || 30),
74
+ algorithm: algorithm.value,
75
+ };
76
+ try {
77
+ const next = await totp(secretBytes, options);
78
+ const { left, ratio } = stepProgress(options.period);
79
+ if (next !== current) {
80
+ current = next;
81
+ code.textContent = next.replace(/(.{3})(?=.)/g, "$1 ");
82
+ }
83
+ meta.textContent = `${Math.ceil(left)} s left · ${options.period} s period · ${options.digits} digits · ${options.algorithm}`;
84
+ ring.firstElementChild.style.width = `${Math.round(ratio * 100)}%`;
85
+ error.write("");
86
+ } catch (caught) {
87
+ error.write(caught?.message || "Could not compute the code.");
88
+ }
89
+ }
90
+
91
+ function restart() {
92
+ stop();
93
+ current = "";
94
+ try {
95
+ const parsed = readSecret();
96
+ if (!parsed) {
97
+ secretBytes = null;
98
+ code.textContent = "------";
99
+ meta.textContent = "Paste a secret to start.";
100
+ ring.firstElementChild.style.width = "100%";
101
+ error.write("");
102
+ return;
103
+ }
104
+ secretBytes = parsed.bytes;
105
+ meta.textContent = parsed.label ? `${parsed.label} · reading` : "Reading the secret";
106
+ tick();
107
+ timer = setInterval(tick, 500);
108
+ } catch (caught) {
109
+ secretBytes = null;
110
+ code.textContent = "------";
111
+ meta.textContent = "";
112
+ error.write(caught?.message || "That secret could not be read.");
113
+ }
114
+ }
115
+
116
+ secret.addEventListener("input", restart);
117
+ digits.addEventListener("change", restart);
118
+ period.addEventListener("change", restart);
119
+ algorithm.addEventListener("change", restart);
120
+
121
+ showSecret.input.addEventListener("change", () => {
122
+ secret.type = showSecret.input.checked ? "text" : "password";
123
+ secret.classList.toggle("font-mono", true);
124
+ });
125
+ secret.type = "password";
126
+
127
+ const copyCode = copyButton(() => code.textContent.replace(/\s+/g, ""), "Copy code");
128
+ const newSecret = button("New secret", {
129
+ icon: "refresh-cw",
130
+ onclick: () => {
131
+ secret.type = "text";
132
+ showSecret.input.checked = true;
133
+ secret.value = randomSecret(20);
134
+ restart();
135
+ toast("New secret generated");
136
+ },
137
+ });
138
+ const copySecret = button("Copy secret", {
139
+ icon: "key-round",
140
+ onclick: async () => {
141
+ if (!secret.value.trim()) return;
142
+ const value = formatSecret(secret.value.trim().startsWith("otpauth") ? parseOtpUri(secret.value).secret : secret.value);
143
+ try {
144
+ await navigator.clipboard.writeText(value);
145
+ toast("Secret copied");
146
+ } catch {
147
+ error.write("The clipboard is blocked here.");
148
+ }
149
+ },
150
+ });
151
+
152
+ const shell = toolcard({
153
+ body: bench(
154
+ stack(
155
+ panel("Secret", secret, "Base32, or a full otpauth:// URI."),
156
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Digits", digits), panel("Period", period)),
157
+ panel("Algorithm", algorithm),
158
+ showSecret,
159
+ error
160
+ ),
161
+ stack(panelBody,
162
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
163
+ "The code is computed in this tab with Web Crypto. Nothing is sent anywhere, and the secret is not stored."))
164
+ ),
165
+ foot: actions(newSecret, copySecret, copyCode),
166
+ });
167
+
168
+ slot.append(shell.node);
169
+ restart();
170
+
171
+ return {
172
+ unmount() {
173
+ stop();
174
+ },
175
+ };
176
+ }
177
+
178
+ export default { mount };
@@ -0,0 +1,183 @@
1
+ // Check a code against a secret, with a drift window. This is the other half of
2
+ // the generator: a failing integration needs to know whether the secret or the
3
+ // clock is wrong, and the drift count is the answer.
4
+ import {
5
+ el, icon, bench, stack, panel, textInput, selectInput, numberInput, button,
6
+ actions, toolcard, notice, chipRow,
7
+ } from "../core.js";
8
+ import { base32Decode, parseOtpUri, totp } from "../totp.js";
9
+
10
+ export function mount(slot) {
11
+ let result = null;
12
+
13
+ const secret = textInput({
14
+ placeholder: "JBSWY3DPEHPK3PXP or otpauth://totp/...",
15
+ "aria-label": "Secret",
16
+ class: "font-mono",
17
+ autocomplete: "off",
18
+ spellcheck: "false",
19
+ });
20
+ const codeInput = textInput({
21
+ placeholder: "123456",
22
+ "aria-label": "Code",
23
+ class: "font-mono tracking-[0.2em]",
24
+ inputmode: "numeric",
25
+ autocomplete: "off",
26
+ });
27
+ const digits = selectInput(
28
+ [
29
+ { value: "6", label: "6 digits" },
30
+ { value: "8", label: "8 digits" },
31
+ ],
32
+ { value: "6", "aria-label": "Digits" }
33
+ );
34
+ const period = numberInput({ min: "5", max: "300", step: "1", value: "30", "aria-label": "Period in seconds" });
35
+ const drift = numberInput({ min: "0", max: "10", step: "1", value: "1", "aria-label": "Drift steps" });
36
+
37
+ const at = el("input", {
38
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
39
+ type: "datetime-local",
40
+ "aria-label": "Timestamp",
41
+ });
42
+
43
+ const error = notice("warn");
44
+ const verdict = el("div", { class: "grid gap-1.5 rounded-r2 border border-orbit bg-deck-2 p-4" });
45
+ const verdictTitle = el("p", { class: "m-0 text-xl font-bold tracking-[-0.02em]" }, "Waiting");
46
+ const verdictBody = el("p", { class: "m-0 text-sm leading-[1.6] text-ink-2" }, "Paste a secret and a code.");
47
+ const candidates = el("div", { class: "grid gap-1.5" });
48
+ verdict.append(verdictTitle, verdictBody, candidates);
49
+
50
+ const check = button("Check code", { icon: "shield-check", kind: "fill" });
51
+
52
+ function readSecret() {
53
+ const raw = secret.value.trim();
54
+ if (!raw) return null;
55
+ const uri = parseOtpUri(raw);
56
+ if (uri) {
57
+ if (uri.secret) {
58
+ if (uri.digits) digits.value = String(uri.digits === 8 ? 8 : 6);
59
+ if (uri.period) period.value = String(uri.period);
60
+ }
61
+ return base32Decode(uri.secret);
62
+ }
63
+ return base32Decode(raw);
64
+ }
65
+
66
+ function paint(state, title, body, rows = []) {
67
+ const tone = state === "ok"
68
+ ? "border-jade bg-jade-soft text-jade"
69
+ : state === "bad"
70
+ ? "border-danger/40 bg-danger/10 text-danger"
71
+ : "border-orbit bg-deck-2 text-ink";
72
+ verdict.className = `grid gap-1.5 rounded-r2 border p-4 ${tone}`;
73
+ verdictTitle.textContent = title;
74
+ verdictBody.textContent = body;
75
+ verdictBody.className = state === "ok" || state === "bad" ? "m-0 text-sm leading-[1.6]" : "m-0 text-sm leading-[1.6] text-ink-2";
76
+ candidates.replaceChildren(
77
+ ...rows.map((row) =>
78
+ el("div", { class: "flex items-center justify-between gap-3 border-t border-current/15 pt-1.5 text-sm" },
79
+ el("span", { class: "font-mono text-2xs tabular-nums" }, row.code),
80
+ el("span", { class: "text-2xs font-semibold" }, row.label)))
81
+ );
82
+ }
83
+
84
+ async function run() {
85
+ error.write("");
86
+ candidates.replaceChildren();
87
+ try {
88
+ const bytes = readSecret();
89
+ if (!bytes) {
90
+ paint("idle", "Waiting", "Paste a secret and a code.");
91
+ return;
92
+ }
93
+ const typed = codeInput.value.replace(/\s+/g, "");
94
+ if (!/^\d{4,10}$/.test(typed)) {
95
+ paint("idle", "Waiting", "The code should be digits only.");
96
+ return;
97
+ }
98
+
99
+ const size = Number(digits.value) || 6;
100
+ const step = Math.max(5, Number(period.value) || 30);
101
+ const window = Math.max(0, Math.min(10, Number(drift.value) || 0));
102
+ const stamp = at.value ? new Date(at.value).getTime() : Date.now();
103
+ if (Number.isNaN(stamp)) throw new Error("That timestamp is not a date.");
104
+
105
+ const rows = [];
106
+ let match = null;
107
+ for (let offset = -window; offset <= window; offset++) {
108
+ const candidate = await totp(bytes, {
109
+ digits: size,
110
+ period: step,
111
+ timestamp: stamp + offset * step * 1000,
112
+ });
113
+ const label = offset === 0 ? "current step" : offset < 0 ? `${-offset} step earlier` : `${offset} step later`;
114
+ rows.push({ code: candidate, label });
115
+ if (candidate === typed && !match) match = { offset, code: candidate };
116
+ }
117
+
118
+ if (match) {
119
+ paint(
120
+ "ok",
121
+ "Match",
122
+ match.offset === 0
123
+ ? "The code is valid for the current step."
124
+ : `The code is valid, ${Math.abs(match.offset)} step${Math.abs(match.offset) === 1 ? "" : "s"} ${match.offset < 0 ? "earlier" : "later"} than the current one.`,
125
+ rows
126
+ );
127
+ } else {
128
+ paint(
129
+ "bad",
130
+ "No match",
131
+ `None of the ${rows.length} steps around that time produce ${typed}. Check the secret, the digits and the period.`,
132
+ rows
133
+ );
134
+ }
135
+ } catch (caught) {
136
+ paint("idle", "Waiting", "");
137
+ error.write(caught?.message || "That secret could not be read.");
138
+ }
139
+ }
140
+
141
+ check.addEventListener("click", run);
142
+ secret.addEventListener("input", run);
143
+ codeInput.addEventListener("input", run);
144
+ digits.addEventListener("change", run);
145
+ period.addEventListener("change", run);
146
+ drift.addEventListener("input", run);
147
+ at.addEventListener("change", run);
148
+
149
+ const useNow = button("Use now", {
150
+ icon: "clock",
151
+ kind: "quiet",
152
+ onclick: () => {
153
+ const now = new Date();
154
+ const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
155
+ at.value = local;
156
+ run();
157
+ },
158
+ });
159
+
160
+ run();
161
+
162
+ const shell = toolcard({
163
+ body: bench(
164
+ stack(
165
+ panel("Secret", secret, "Base32, or a full otpauth:// URI."),
166
+ panel("Code to check", codeInput),
167
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Digits", digits), panel("Period", period)),
168
+ panel("Drift window", drift, "How many steps either side to accept. One is the usual allowance."),
169
+ panel("Timestamp", at, "Leave empty to use the clock now."),
170
+ useNow,
171
+ error
172
+ ),
173
+ stack(verdict)
174
+ ),
175
+ foot: actions(check),
176
+ });
177
+
178
+ slot.append(shell.node);
179
+
180
+ return {};
181
+ }
182
+
183
+ export default { mount };
@@ -0,0 +1,117 @@
1
+ // TOTP, the six digit code an authenticator app shows. RFC 6238 over RFC 4226,
2
+ // with HMAC from Web Crypto, so the secret never leaves the tab.
3
+ //
4
+ // The secret is Base32, which is why this file carries a decoder: every
5
+ // `otpauth://` URI and every authenticator export uses that alphabet.
6
+
7
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
8
+
9
+ /** Base32 to bytes. Case insensitive, spaces and padding ignored. */
10
+ export function base32Decode(input) {
11
+ const clean = String(input).toUpperCase().replace(/[\s=-]/g, "");
12
+ if (!clean) throw new Error("The secret is empty.");
13
+ let bits = 0;
14
+ let value = 0;
15
+ const out = [];
16
+ for (const char of clean) {
17
+ const index = ALPHABET.indexOf(char);
18
+ if (index < 0) throw new Error(`"${char}" is not a Base32 character.`);
19
+ value = (value << 5) | index;
20
+ bits += 5;
21
+ if (bits >= 8) {
22
+ out.push((value >>> (bits - 8)) & 0xff);
23
+ bits -= 8;
24
+ }
25
+ }
26
+ if (!out.length) throw new Error("That secret decodes to no bytes.");
27
+ return new Uint8Array(out);
28
+ }
29
+
30
+ /** Parses an `otpauth://totp/...` URI into its parts, or returns null. */
31
+ export function parseOtpUri(text) {
32
+ const clean = String(text).trim();
33
+ if (!clean.toLowerCase().startsWith("otpauth://")) return null;
34
+ let url;
35
+ try {
36
+ url = new URL(clean);
37
+ } catch {
38
+ throw new Error("That otpauth URI is not a valid URL.");
39
+ }
40
+ const params = url.searchParams;
41
+ const label = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
42
+ return {
43
+ type: (url.hostname || "totp").toLowerCase(),
44
+ label,
45
+ issuer: params.get("issuer") ?? "",
46
+ secret: params.get("secret") ?? "",
47
+ digits: Number(params.get("digits") ?? 6),
48
+ period: Number(params.get("period") ?? 30),
49
+ algorithm: (params.get("algorithm") ?? "SHA1").toUpperCase().replace("-", ""),
50
+ };
51
+ }
52
+
53
+ const HASH = { SHA1: "SHA-1", SHA256: "SHA-256", SHA512: "SHA-512" };
54
+
55
+ /** HOTP for one counter value, as a zero padded string. */
56
+ export async function hotp(secret, counter, { digits = 6, algorithm = "SHA1" } = {}) {
57
+ const hash = HASH[algorithm] ?? "SHA-1";
58
+ const key = await crypto.subtle.importKey(
59
+ "raw",
60
+ secret,
61
+ { name: "HMAC", hash },
62
+ false,
63
+ ["sign"]
64
+ );
65
+ // The counter is an 8 byte big endian integer. JavaScript numbers lose
66
+ // precision above 2^53, so the high word is written separately.
67
+ const buffer = new ArrayBuffer(8);
68
+ const view = new DataView(buffer);
69
+ view.setUint32(0, Math.floor(counter / 2 ** 32));
70
+ view.setUint32(4, counter >>> 0);
71
+ const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, buffer));
72
+
73
+ // Dynamic truncation, RFC 4226 section 5.4.
74
+ const offset = signature[signature.length - 1] & 0x0f;
75
+ const binary =
76
+ ((signature[offset] & 0x7f) << 24) |
77
+ (signature[offset + 1] << 16) |
78
+ (signature[offset + 2] << 8) |
79
+ signature[offset + 3];
80
+ return String(binary % 10 ** digits).padStart(digits, "0");
81
+ }
82
+
83
+ /** The TOTP code for a timestamp in milliseconds. */
84
+ export async function totp(secret, { digits = 6, period = 30, algorithm = "SHA1", timestamp = Date.now() } = {}) {
85
+ const counter = Math.floor(timestamp / 1000 / period);
86
+ return hotp(secret, counter, { digits, algorithm });
87
+ }
88
+
89
+ /** Seconds left in the current step, and how far through it we are. */
90
+ export function stepProgress(period = 30, timestamp = Date.now()) {
91
+ const seconds = timestamp / 1000;
92
+ const left = period - (seconds % period);
93
+ return { left, ratio: left / period };
94
+ }
95
+
96
+ /** The secret as spaced groups, which is how authenticator apps show it. */
97
+ export function formatSecret(text) {
98
+ return String(text).toUpperCase().replace(/[^A-Z2-7]/g, "").replace(/(.{4})/g, "$1 ").trim();
99
+ }
100
+
101
+ /** A random Base32 secret of the given byte length. */
102
+ export function randomSecret(bytes = 20) {
103
+ const raw = crypto.getRandomValues(new Uint8Array(bytes));
104
+ let bits = 0;
105
+ let value = 0;
106
+ let out = "";
107
+ for (const byte of raw) {
108
+ value = (value << 8) | byte;
109
+ bits += 8;
110
+ while (bits >= 5) {
111
+ out += ALPHABET[(value >>> (bits - 5)) & 31];
112
+ bits -= 5;
113
+ }
114
+ }
115
+ if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31];
116
+ return out;
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",