mevebu 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/mevebu/app/tailwind.css +66 -3
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/random.js +54 -0
- package/mevebu/libs/js/registry.js +180 -0
- package/mevebu/libs/js/tools/aes-text.js +192 -0
- package/mevebu/libs/js/tools/dataset-splitter.js +181 -0
- package/mevebu/libs/js/tools/file-hash.js +154 -0
- package/mevebu/libs/js/tools/json-schema-sample.js +254 -0
- package/mevebu/libs/js/tools/mock-api-payload.js +224 -0
- package/mevebu/libs/js/tools/passphrase-generator.js +149 -0
- package/mevebu/libs/js/tools/sample-csv.js +2 -36
- package/mevebu/libs/js/tools/survey-responses.js +225 -0
- package/mevebu/libs/js/tools/time-series.js +191 -0
- package/mevebu/libs/js/tools/totp-generator.js +178 -0
- package/mevebu/libs/js/tools/totp-verifier.js +183 -0
- package/mevebu/libs/js/totp.js +117 -0
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// A dated series with a trend, a season and noise, so a demo chart looks like
|
|
2
|
+
// data instead of a random walk. Seeded, and the shape is previewed before it
|
|
3
|
+
// is downloaded.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, download, bench, stack, panel, numberInput, textInput, selectInput,
|
|
6
|
+
button, actions, toolcard, notice,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
import { toCsv } from "../csv.js";
|
|
9
|
+
import { seeded, gaussian } from "../random.js";
|
|
10
|
+
|
|
11
|
+
const FREQUENCIES = [
|
|
12
|
+
{ value: "hour", label: "Hourly" },
|
|
13
|
+
{ value: "day", label: "Daily" },
|
|
14
|
+
{ value: "week", label: "Weekly" },
|
|
15
|
+
{ value: "month", label: "Monthly" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function step(date, frequency) {
|
|
19
|
+
const next = new Date(date);
|
|
20
|
+
if (frequency === "hour") next.setHours(next.getHours() + 1);
|
|
21
|
+
else if (frequency === "day") next.setDate(next.getDate() + 1);
|
|
22
|
+
else if (frequency === "week") next.setDate(next.getDate() + 7);
|
|
23
|
+
else next.setMonth(next.getMonth() + 1);
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function iso(date, frequency) {
|
|
28
|
+
const year = date.getFullYear();
|
|
29
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
30
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
31
|
+
if (frequency === "hour") return `${year}-${month}-${day} ${String(date.getHours()).padStart(2, "0")}:00`;
|
|
32
|
+
return `${year}-${month}-${day}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mount(slot) {
|
|
36
|
+
const start = el("input", {
|
|
37
|
+
class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
38
|
+
type: "date",
|
|
39
|
+
value: (() => {
|
|
40
|
+
const now = new Date();
|
|
41
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
42
|
+
})(),
|
|
43
|
+
"aria-label": "Start date",
|
|
44
|
+
});
|
|
45
|
+
const frequency = selectInput(FREQUENCIES, { value: "day", "aria-label": "Frequency" });
|
|
46
|
+
const length = numberInput({ min: "2", max: "2000", step: "1", value: "60", "aria-label": "Points" });
|
|
47
|
+
const base = numberInput({ value: "100", step: "1", "aria-label": "Base value" });
|
|
48
|
+
const trend = numberInput({ value: "0.4", step: "0.1", "aria-label": "Trend per point" });
|
|
49
|
+
const season = numberInput({ value: "12", step: "1", "aria-label": "Season amplitude" });
|
|
50
|
+
const period = numberInput({ min: "2", max: "365", step: "1", value: "7", "aria-label": "Season period" });
|
|
51
|
+
const noise = numberInput({ min: "0", max: "100", step: "0.5", value: "4", "aria-label": "Noise" });
|
|
52
|
+
const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
|
|
53
|
+
const spikeDate = el("input", {
|
|
54
|
+
class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
55
|
+
type: "date",
|
|
56
|
+
"aria-label": "Spike date",
|
|
57
|
+
});
|
|
58
|
+
const spikeMultiplier = numberInput({ value: "2", step: "0.1", "aria-label": "Spike multiplier" });
|
|
59
|
+
|
|
60
|
+
const error = notice("warn");
|
|
61
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
62
|
+
const chart = el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-3" });
|
|
63
|
+
const result = el("pre", { class: "m-0 max-h-56 overflow-auto rounded-r2 border border-orbit bg-deck-2 p-3 font-mono text-2xs leading-[1.6]" });
|
|
64
|
+
|
|
65
|
+
let series = [];
|
|
66
|
+
|
|
67
|
+
function build() {
|
|
68
|
+
const count = Math.max(2, Math.min(2000, Number(length.value) || 2));
|
|
69
|
+
const random = seeded(seed.value || "mevebu");
|
|
70
|
+
const amp = Number(season.value) || 0;
|
|
71
|
+
const slope = Number(trend.value) || 0;
|
|
72
|
+
const spread = Number(noise.value) || 0;
|
|
73
|
+
const wave = Math.max(2, Number(period.value) || 7);
|
|
74
|
+
const startDate = new Date(`${start.value}T00:00:00`);
|
|
75
|
+
if (Number.isNaN(startDate.getTime())) {
|
|
76
|
+
error.write("Pick a start date.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const out = [];
|
|
81
|
+
let date = startDate;
|
|
82
|
+
for (let i = 0; i < count; i++) {
|
|
83
|
+
const seasonal = amp * Math.sin((2 * Math.PI * i) / wave);
|
|
84
|
+
const drift = slope * i;
|
|
85
|
+
const jitter = spread ? gaussian(random, 0, spread) : 0;
|
|
86
|
+
out.push({ date: iso(date, frequency.value), value: Number((Number(base.value || 0) + drift + seasonal + jitter).toFixed(2)) });
|
|
87
|
+
date = step(date, frequency.value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (spikeDate.value && Number(spikeMultiplier.value)) {
|
|
91
|
+
const target = spikeDate.value;
|
|
92
|
+
const hit = out.find((point) => point.date.startsWith(target));
|
|
93
|
+
if (hit) hit.value = Number((hit.value * Number(spikeMultiplier.value)).toFixed(2));
|
|
94
|
+
else error.write("The spike date is outside the series, so it was skipped.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
series = out;
|
|
98
|
+
render();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function render() {
|
|
102
|
+
if (!series.length) {
|
|
103
|
+
summary.textContent = "";
|
|
104
|
+
chart.replaceChildren();
|
|
105
|
+
result.textContent = "";
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const values = series.map((point) => point.value);
|
|
109
|
+
const min = Math.min(...values);
|
|
110
|
+
const max = Math.max(...values);
|
|
111
|
+
const span = max - min || 1;
|
|
112
|
+
const width = 100;
|
|
113
|
+
const height = 34;
|
|
114
|
+
|
|
115
|
+
// One polyline, drawn in a fixed viewBox, so the preview scales without a
|
|
116
|
+
// chart library.
|
|
117
|
+
const points = series
|
|
118
|
+
.map((point, index) => {
|
|
119
|
+
const x = (index / Math.max(1, series.length - 1)) * width;
|
|
120
|
+
const y = height - ((point.value - min) / span) * height;
|
|
121
|
+
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
122
|
+
})
|
|
123
|
+
.join(" ");
|
|
124
|
+
|
|
125
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
126
|
+
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
127
|
+
svg.setAttribute("preserveAspectRatio", "none");
|
|
128
|
+
svg.setAttribute("class", "h-40 w-full");
|
|
129
|
+
svg.setAttribute("role", "img");
|
|
130
|
+
svg.setAttribute("aria-label", `Series preview, ${series.length} points`);
|
|
131
|
+
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
|
132
|
+
line.setAttribute("points", points);
|
|
133
|
+
line.setAttribute("fill", "none");
|
|
134
|
+
line.setAttribute("stroke", "var(--jade)");
|
|
135
|
+
line.setAttribute("stroke-width", "0.7");
|
|
136
|
+
line.setAttribute("vector-effect", "non-scaling-stroke");
|
|
137
|
+
svg.append(line);
|
|
138
|
+
|
|
139
|
+
chart.replaceChildren(
|
|
140
|
+
svg,
|
|
141
|
+
el("div", { class: "flex justify-between text-2xs text-ink-3 tabular-nums" },
|
|
142
|
+
el("span", {}, series[0].date),
|
|
143
|
+
el("span", {}, `${min} to ${max}`),
|
|
144
|
+
el("span", {}, series[series.length - 1].date))
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const preview = [["date", "value"], ...series.slice(0, 40).map((point) => [point.date, String(point.value)])];
|
|
148
|
+
result.textContent = toCsv(preview) + (series.length > 40 ? `\n… ${series.length - 40} more rows` : "");
|
|
149
|
+
summary.textContent = `${series.length} points · ${frequency.value} · ${min} to ${max} · seed ${seed.value || "mevebu"}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const node of [start, frequency, length, base, trend, season, period, noise, seed, spikeDate, spikeMultiplier]) {
|
|
153
|
+
node.addEventListener("input", build);
|
|
154
|
+
node.addEventListener("change", build);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const downloadButton = button("Download CSV", {
|
|
158
|
+
icon: "download",
|
|
159
|
+
kind: "fill",
|
|
160
|
+
onclick: () => {
|
|
161
|
+
if (!series.length) return;
|
|
162
|
+
download("series.csv", toCsv([["date", "value"], ...series.map((point) => [point.date, String(point.value)])]), "text/csv");
|
|
163
|
+
toast("Series ready");
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
build();
|
|
168
|
+
|
|
169
|
+
const shell = toolcard({
|
|
170
|
+
body: bench(
|
|
171
|
+
stack(
|
|
172
|
+
el("div", { class: "grid grid-cols-2 gap-3" }, panel("Start", start), panel("Frequency", frequency)),
|
|
173
|
+
panel("Points", length),
|
|
174
|
+
el("div", { class: "grid grid-cols-2 gap-3" }, panel("Base", base), panel("Trend per point", trend)),
|
|
175
|
+
el("div", { class: "grid grid-cols-2 gap-3" }, panel("Season amplitude", season), panel("Season period", period)),
|
|
176
|
+
panel("Noise", noise),
|
|
177
|
+
panel("Seed", seed),
|
|
178
|
+
el("div", { class: "grid grid-cols-2 gap-3" }, panel("Spike date", spikeDate), panel("Spike ×", spikeMultiplier)),
|
|
179
|
+
error
|
|
180
|
+
),
|
|
181
|
+
stack(summary, chart, result)
|
|
182
|
+
),
|
|
183
|
+
foot: actions(downloadButton),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
slot.append(shell.node);
|
|
187
|
+
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
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
|
+
}
|