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 +7 -7
- package/mevebu/app/tailwind.css +57 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/registry.js +90 -0
- package/mevebu/libs/js/tools/aes-text.js +192 -0
- package/mevebu/libs/js/tools/file-hash.js +154 -0
- package/mevebu/libs/js/tools/passphrase-generator.js +149 -0
- package/mevebu/libs/js/tools/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
71
|
| Data Generator | 10 |
|
|
72
|
-
| Passwords and keys |
|
|
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** | **
|
|
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
|
@@ -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;
|
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
|
|
|
@@ -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
|
+
}
|