mevebu 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }