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.
- package/README.md +7 -7
- package/mevebu/app/tailwind.css +69 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/registry.js +180 -0
- package/mevebu/libs/js/tools/aes-text.js +192 -0
- package/mevebu/libs/js/tools/bcrypt-hash.js +161 -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/password-policy.js +139 -0
- package/mevebu/libs/js/tools/rsa-key-pair.js +137 -0
- package/mevebu/libs/js/tools/secret-scanner.js +168 -0
- package/mevebu/libs/js/tools/ssh-fingerprint.js +210 -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/mevebu/libs/js/vendor/bcrypt.min.js +1220 -0
- package/mevebu/libs/js/vendor/bcryptjs.LICENSE +27 -0
- package/package.json +2 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// An RSA key pair made in the tab with Web Crypto. The keys exist only in the
|
|
2
|
+
// page until it closes, and both come out as PEM, the form most tools expect.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, selectInput, button, actions,
|
|
5
|
+
toolcard, blank, notice, copyButton, output, checkbox,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
|
|
8
|
+
const SIZES = [
|
|
9
|
+
{ value: "2048", label: "2048 bits" },
|
|
10
|
+
{ value: "3072", label: "3072 bits" },
|
|
11
|
+
{ value: "4096", label: "4096 bits" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function wrap(base64) {
|
|
15
|
+
return base64.match(/.{1,64}/g).join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toPem(label, der) {
|
|
19
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(der)));
|
|
20
|
+
return `-----BEGIN ${label}-----\n${wrap(base64)}\n-----END ${label}-----\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mount(slot) {
|
|
24
|
+
let pair = null;
|
|
25
|
+
let busy = false;
|
|
26
|
+
let token = 0;
|
|
27
|
+
|
|
28
|
+
const size = selectInput(SIZES, { value: "2048", "aria-label": "Key size" });
|
|
29
|
+
const showPrivate = checkbox("Show the private key", { checked: false });
|
|
30
|
+
|
|
31
|
+
const error = notice("warn");
|
|
32
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
33
|
+
const publicOut = output({ rows: 8 });
|
|
34
|
+
const privateOut = output({ rows: 10 });
|
|
35
|
+
const empty = blank("key-round", "Generate a pair to see the keys.");
|
|
36
|
+
const publicBox = el("div", { class: "grid gap-1.5" },
|
|
37
|
+
el("span", { class: "text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase" }, "Public key"), publicOut);
|
|
38
|
+
const privateBox = el("div", { class: "grid gap-1.5" },
|
|
39
|
+
el("span", { class: "text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase" }, "Private key"), privateOut);
|
|
40
|
+
|
|
41
|
+
const generateButton = button("Generate pair", { icon: "refresh-cw", kind: "fill", onclick: generate });
|
|
42
|
+
const copyPublic = copyButton(() => pair?.publicPem ?? "", "Copy public");
|
|
43
|
+
const copyPrivate = copyButton(() => pair?.privatePem ?? "", "Copy private");
|
|
44
|
+
const downloadPublic = button("Download public", {
|
|
45
|
+
icon: "download",
|
|
46
|
+
onclick: () => { if (pair) download("public.pem", pair.publicPem, "application/x-pem-file"); },
|
|
47
|
+
});
|
|
48
|
+
const downloadPrivate = button("Download private", {
|
|
49
|
+
icon: "download",
|
|
50
|
+
onclick: () => { if (pair) download("private.pem", pair.privatePem, "application/x-pem-file"); },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function sync() {
|
|
54
|
+
const hasKeys = Boolean(pair);
|
|
55
|
+
publicBox.hidden = !hasKeys;
|
|
56
|
+
privateBox.hidden = !hasKeys || !showPrivate.input.checked;
|
|
57
|
+
empty.hidden = hasKeys;
|
|
58
|
+
privateOut.hidden = privateBox.hidden;
|
|
59
|
+
copyPrivate.disabled = !hasKeys;
|
|
60
|
+
copyPublic.disabled = !hasKeys;
|
|
61
|
+
downloadPrivate.disabled = !hasKeys;
|
|
62
|
+
downloadPublic.disabled = !hasKeys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function generate() {
|
|
66
|
+
if (busy) return;
|
|
67
|
+
busy = true;
|
|
68
|
+
const current = ++token;
|
|
69
|
+
generateButton.disabled = true;
|
|
70
|
+
summary.textContent = "Generating. Larger keys take a few seconds.";
|
|
71
|
+
error.write("");
|
|
72
|
+
try {
|
|
73
|
+
const keys = await crypto.subtle.generateKey(
|
|
74
|
+
{
|
|
75
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
76
|
+
modulusLength: Number(size.value),
|
|
77
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
78
|
+
hash: "SHA-256",
|
|
79
|
+
},
|
|
80
|
+
true,
|
|
81
|
+
["sign", "verify"]
|
|
82
|
+
);
|
|
83
|
+
const spki = await crypto.subtle.exportKey("spki", keys.publicKey);
|
|
84
|
+
const pkcs8 = await crypto.subtle.exportKey("pkcs8", keys.privateKey);
|
|
85
|
+
if (current !== token) return;
|
|
86
|
+
pair = {
|
|
87
|
+
bits: Number(size.value),
|
|
88
|
+
publicPem: toPem("PUBLIC KEY", spki),
|
|
89
|
+
privatePem: toPem("PRIVATE KEY", pkcs8),
|
|
90
|
+
};
|
|
91
|
+
publicOut.write(pair.publicPem);
|
|
92
|
+
privateOut.write(pair.privatePem);
|
|
93
|
+
summary.textContent = `${pair.bits}-bit RSA · SHA-256 · exponent 65537 · PKCS#8 private, SPKI public`;
|
|
94
|
+
sync();
|
|
95
|
+
toast("Key pair ready");
|
|
96
|
+
} catch (caught) {
|
|
97
|
+
if (current !== token) return;
|
|
98
|
+
error.write(caught?.message || "The browser could not generate this key.");
|
|
99
|
+
} finally {
|
|
100
|
+
busy = false;
|
|
101
|
+
generateButton.disabled = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
size.addEventListener("change", () => {
|
|
106
|
+
if (pair) {
|
|
107
|
+
pair = null;
|
|
108
|
+
publicOut.write("");
|
|
109
|
+
privateOut.write("");
|
|
110
|
+
summary.textContent = "";
|
|
111
|
+
sync();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
showPrivate.input.addEventListener("change", sync);
|
|
115
|
+
|
|
116
|
+
sync();
|
|
117
|
+
|
|
118
|
+
const shell = toolcard({
|
|
119
|
+
body: bench(
|
|
120
|
+
stack(
|
|
121
|
+
panel("Key size", size, "2048 is the common floor. 4096 is slower and rarely needed."),
|
|
122
|
+
showPrivate,
|
|
123
|
+
error,
|
|
124
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
125
|
+
"Generated with the browser's RSA implementation. The keys exist only in this page; closing it discards them. For production, generate keys on a machine you control.")
|
|
126
|
+
),
|
|
127
|
+
stack(summary, empty, publicBox, privateBox)
|
|
128
|
+
),
|
|
129
|
+
foot: actions(downloadPublic, downloadPrivate, copyPublic, copyPrivate, generateButton),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
slot.append(shell.node);
|
|
133
|
+
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default { mount };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Finds API keys, tokens and private keys in a block of text before it is
|
|
2
|
+
// pasted somewhere. It matches known shapes, so a clean result is a hint, not a
|
|
3
|
+
// guarantee. Every value is masked unless you ask for it.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, bench, stack, panel, checkbox, button, actions, toolcard, blank,
|
|
6
|
+
notice, output, copyButton,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
import { dropzone, formatBytes } from "../files.js";
|
|
9
|
+
|
|
10
|
+
const RULES = [
|
|
11
|
+
{ id: "aws", label: "AWS access key", re: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/g, group: 1 },
|
|
12
|
+
{ id: "github", label: "GitHub token", re: /\b((?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{22,}))\b/g, group: 1 },
|
|
13
|
+
{ id: "slack", label: "Slack token", re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g, group: 1 },
|
|
14
|
+
{ id: "stripe", label: "Stripe key", re: /\b((?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,})\b/g, group: 1 },
|
|
15
|
+
{ id: "google", label: "Google API key", re: /\b(AIza[0-9A-Za-z_\-]{35})\b/g, group: 1 },
|
|
16
|
+
{ id: "jwt", label: "JSON Web Token", re: /\b(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})\b/g, group: 1 },
|
|
17
|
+
{ id: "pem", label: "Private key block", re: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----)/g, group: 1 },
|
|
18
|
+
{ id: "basic", label: "Basic auth in a URL", re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@)/gi, group: 1 },
|
|
19
|
+
{ id: "sql", label: "Database password in a string", re: /\b(?:password|passwd|pwd)\s*=\s*['"]([^'"]{6,})['"]/gi, group: 1 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function mask(value) {
|
|
23
|
+
if (value.length <= 8) return `${value.slice(0, 2)}${"*".repeat(Math.max(2, value.length - 2))}`;
|
|
24
|
+
return `${value.slice(0, 4)}${"*".repeat(Math.min(12, value.length - 8))}${value.slice(-4)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lineAt(text, index) {
|
|
28
|
+
let line = 1;
|
|
29
|
+
for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
|
|
30
|
+
return line;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mount(slot) {
|
|
34
|
+
let findings = [];
|
|
35
|
+
|
|
36
|
+
const reveal = checkbox("Show the matched values", { checked: false });
|
|
37
|
+
const error = notice("warn");
|
|
38
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
39
|
+
const list = el("div", { class: "grid gap-2" });
|
|
40
|
+
const empty = blank("shield-alert", "Paste text or drop a file to scan it.");
|
|
41
|
+
const scanned = output({ rows: 8 });
|
|
42
|
+
|
|
43
|
+
const text = el("textarea", {
|
|
44
|
+
class: "w-full min-w-0 min-h-32 resize-y rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm leading-normal focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
45
|
+
placeholder: "Paste a config, an env list, or a diff",
|
|
46
|
+
rows: "8",
|
|
47
|
+
spellcheck: "false",
|
|
48
|
+
autocomplete: "off",
|
|
49
|
+
"aria-label": "Text to scan",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function render() {
|
|
53
|
+
const showValues = reveal.input.checked;
|
|
54
|
+
list.replaceChildren(
|
|
55
|
+
...findings.map((finding, index) =>
|
|
56
|
+
el("div", { class: "grid gap-1 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
|
|
57
|
+
el("div", { class: "flex flex-wrap items-center gap-2" },
|
|
58
|
+
el("span", { class: "rounded-full bg-danger/15 px-2 py-0.5 text-2xs font-bold text-danger" }, finding.label),
|
|
59
|
+
el("span", { class: "text-2xs text-ink-3" }, `line ${finding.line}`),
|
|
60
|
+
el("span", { class: "ms-auto font-mono text-2xs text-ink-2" }, showValues ? finding.value : mask(finding.value)),
|
|
61
|
+
copyButton(() => finding.value, "Copy")),
|
|
62
|
+
el("p", { class: "m-0 wrap-anywhere font-mono text-2xs text-ink-3" },
|
|
63
|
+
`${truncate(finding.value, showValues ? 120 : 48)}${showValues ? "" : " masked"}`)))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function truncate(value, max) {
|
|
68
|
+
return value.length <= max ? value : `${value.slice(0, max)}...`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scan() {
|
|
72
|
+
const source = text.value;
|
|
73
|
+
findings = [];
|
|
74
|
+
if (!source.trim()) {
|
|
75
|
+
summary.textContent = "";
|
|
76
|
+
list.replaceChildren();
|
|
77
|
+
empty.hidden = false;
|
|
78
|
+
scanned.hidden = true;
|
|
79
|
+
error.write("");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const rule of RULES) {
|
|
83
|
+
rule.re.lastIndex = 0;
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = rule.re.exec(source)) !== null) {
|
|
86
|
+
findings.push({ id: rule.id, label: rule.label, value: match[rule.group], line: lineAt(source, match.index) });
|
|
87
|
+
if (rule.re.lastIndex === match.index) rule.re.lastIndex++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
findings.sort((a, b) => a.line - b.line);
|
|
91
|
+
summary.textContent = findings.length
|
|
92
|
+
? `${findings.length} possible secret${findings.length === 1 ? "" : "s"} in ${source.split("\n").length} lines`
|
|
93
|
+
: `No known secret shape in ${source.split("\n").length} lines`;
|
|
94
|
+
if (!findings.length) error.write("No known secret shape found. This is a hint, not a guarantee: an unfamiliar secret will pass.");
|
|
95
|
+
else error.write("Treat anything below as compromised and rotate it. The values stay in this page.");
|
|
96
|
+
list.hidden = findings.length === 0;
|
|
97
|
+
empty.hidden = findings.length > 0;
|
|
98
|
+
render();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
text.addEventListener("input", scan);
|
|
102
|
+
reveal.input.addEventListener("change", render);
|
|
103
|
+
|
|
104
|
+
const drop = dropzone({ label: "Drop a file to scan it", accept: "", iconName: "shield-alert" });
|
|
105
|
+
drop.onFiles(async (files) => {
|
|
106
|
+
const picked = Array.from(files)[0];
|
|
107
|
+
if (!picked) return;
|
|
108
|
+
try {
|
|
109
|
+
text.value = await picked.text();
|
|
110
|
+
summary.textContent = `Read ${picked.name} · ${formatBytes(picked.size)}`;
|
|
111
|
+
scan();
|
|
112
|
+
} catch (caught) {
|
|
113
|
+
error.write(caught?.message || "That file could not be read.");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const copyReport = button("Copy report", {
|
|
118
|
+
icon: "copy",
|
|
119
|
+
onclick: async () => {
|
|
120
|
+
const text_ = findings.length
|
|
121
|
+
? findings.map((f) => `line ${f.line}\t${f.label}\t${f.value}`).join("\n")
|
|
122
|
+
: "No known secret shape found.";
|
|
123
|
+
try {
|
|
124
|
+
await navigator.clipboard.writeText(text_);
|
|
125
|
+
toast("Report copied");
|
|
126
|
+
} catch {
|
|
127
|
+
error.write("The clipboard is blocked here.");
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const clear = button("Clear", {
|
|
133
|
+
icon: "trash",
|
|
134
|
+
kind: "quiet",
|
|
135
|
+
onclick: () => {
|
|
136
|
+
text.value = "";
|
|
137
|
+
findings = [];
|
|
138
|
+
summary.textContent = "";
|
|
139
|
+
error.write("");
|
|
140
|
+
list.replaceChildren();
|
|
141
|
+
empty.hidden = false;
|
|
142
|
+
scanned.hidden = true;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
scan();
|
|
147
|
+
|
|
148
|
+
const shell = toolcard({
|
|
149
|
+
body: bench(
|
|
150
|
+
stack(
|
|
151
|
+
text,
|
|
152
|
+
drop,
|
|
153
|
+
reveal,
|
|
154
|
+
error,
|
|
155
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
156
|
+
"Matches known shapes for AWS, GitHub, Slack, Stripe, Google, JWTs, private keys, and credentials in URLs. Nothing is sent anywhere.")
|
|
157
|
+
),
|
|
158
|
+
stack(summary, empty, list, scanned)
|
|
159
|
+
),
|
|
160
|
+
foot: actions(clear, copyReport),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
slot.append(shell.node);
|
|
164
|
+
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default { mount };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The fingerprint of an SSH public key, in the SHA256 form that a server prints.
|
|
2
|
+
// Comparing it with the host you are about to trust is the step people skip
|
|
3
|
+
// because the command is awkward.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, bench, stack, panel, textArea, button, actions, toolcard, blank,
|
|
6
|
+
notice, output, copyButton, chipRow,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
10
|
+
|
|
11
|
+
function decodeBase64(text) {
|
|
12
|
+
const clean = text.replace(/\s+/g, "");
|
|
13
|
+
if (!clean) throw new Error("The key is empty.");
|
|
14
|
+
const bits = [];
|
|
15
|
+
for (const char of clean) {
|
|
16
|
+
if (char === "=") break;
|
|
17
|
+
const index = BASE64.indexOf(char);
|
|
18
|
+
if (index < 0) throw new Error(`"${char}" is not a Base64 character.`);
|
|
19
|
+
for (let bit = 5; bit >= 0; bit--) bits.push((index >> bit) & 1);
|
|
20
|
+
}
|
|
21
|
+
const bytes = [];
|
|
22
|
+
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
23
|
+
let byte = 0;
|
|
24
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
|
|
25
|
+
bytes.push(byte);
|
|
26
|
+
}
|
|
27
|
+
return new Uint8Array(bytes);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The SSH wire format: a length prefixed string for each part. */
|
|
31
|
+
function sshString(bytes) {
|
|
32
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
33
|
+
new DataView(out.buffer).setUint32(0, bytes.length);
|
|
34
|
+
out.set(bytes, 4);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sha256(bytes) {
|
|
39
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function md5(bytes) {
|
|
43
|
+
// A small MD5, only used for the legacy colon form.
|
|
44
|
+
const s = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
45
|
+
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
46
|
+
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
47
|
+
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21];
|
|
48
|
+
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32));
|
|
49
|
+
const a0 = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
|
|
50
|
+
let [A, B, C, D] = a0;
|
|
51
|
+
const padded = new Uint8Array((((bytes.length + 8) >> 6) + 1) * 64);
|
|
52
|
+
padded.set(bytes);
|
|
53
|
+
padded[bytes.length] = 0x80;
|
|
54
|
+
const view = new DataView(padded.buffer);
|
|
55
|
+
view.setUint32(padded.length - 8, (bytes.length << 3) >>> 0, true);
|
|
56
|
+
view.setUint32(padded.length - 4, Math.floor(bytes.length / 2 ** 32), true);
|
|
57
|
+
for (let chunk = 0; chunk < padded.length; chunk += 64) {
|
|
58
|
+
const X = [];
|
|
59
|
+
for (let i = 0; i < 16; i++) X[i] = view.getUint32(chunk + i * 4, true);
|
|
60
|
+
let [a, b, c, d] = [A, B, C, D];
|
|
61
|
+
for (let i = 0; i < 64; i++) {
|
|
62
|
+
let f;
|
|
63
|
+
let g;
|
|
64
|
+
if (i < 16) { f = (b & c) | (~b & d); g = i; }
|
|
65
|
+
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; }
|
|
66
|
+
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
|
|
67
|
+
else { f = c ^ (b | ~d); g = (7 * i) % 16; }
|
|
68
|
+
const tmp = d;
|
|
69
|
+
d = c;
|
|
70
|
+
c = b;
|
|
71
|
+
const sum = (a + f + K[i] + X[g]) | 0;
|
|
72
|
+
b = (b + ((sum << s[i]) | (sum >>> (32 - s[i])))) | 0;
|
|
73
|
+
a = tmp;
|
|
74
|
+
}
|
|
75
|
+
A = (A + a) | 0;
|
|
76
|
+
B = (B + b) | 0;
|
|
77
|
+
C = (C + c) | 0;
|
|
78
|
+
D = (D + d) | 0;
|
|
79
|
+
}
|
|
80
|
+
const out = new Uint8Array(16);
|
|
81
|
+
const outView = new DataView(out.buffer);
|
|
82
|
+
outView.setUint32(0, A, true);
|
|
83
|
+
outView.setUint32(4, B, true);
|
|
84
|
+
outView.setUint32(8, C, true);
|
|
85
|
+
outView.setUint32(12, D, true);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toBase64(bytes) {
|
|
90
|
+
let out = "";
|
|
91
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
92
|
+
const chunk = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
93
|
+
out += BASE64[(chunk >> 18) & 63] + BASE64[(chunk >> 12) & 63];
|
|
94
|
+
out += i + 1 < bytes.length ? BASE64[(chunk >> 6) & 63] : "=";
|
|
95
|
+
out += i + 2 < bytes.length ? BASE64[chunk & 63] : "=";
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function mount(slot) {
|
|
101
|
+
let format = "sha256";
|
|
102
|
+
|
|
103
|
+
const key = textArea({
|
|
104
|
+
rows: 5,
|
|
105
|
+
placeholder: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@host",
|
|
106
|
+
"aria-label": "Public key",
|
|
107
|
+
class: "w-full min-w-0 min-h-28 resize-y rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm leading-normal focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
108
|
+
spellcheck: "false",
|
|
109
|
+
autocomplete: "off",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const error = notice("warn");
|
|
113
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
114
|
+
const result = output({ rows: 3 });
|
|
115
|
+
const empty = blank("fingerprint", "Paste a public key to see its fingerprint.");
|
|
116
|
+
|
|
117
|
+
const mode = chipRow(
|
|
118
|
+
[
|
|
119
|
+
{ value: "sha256", label: "SHA-256" },
|
|
120
|
+
{ value: "md5", label: "MD5" },
|
|
121
|
+
],
|
|
122
|
+
{
|
|
123
|
+
value: "sha256",
|
|
124
|
+
label: "Hash",
|
|
125
|
+
onChange: (value) => {
|
|
126
|
+
format = value;
|
|
127
|
+
run();
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
async function run() {
|
|
133
|
+
const source = key.value.trim();
|
|
134
|
+
if (!source) {
|
|
135
|
+
summary.textContent = "";
|
|
136
|
+
result.write("");
|
|
137
|
+
empty.hidden = false;
|
|
138
|
+
result.hidden = true;
|
|
139
|
+
error.write("");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const parts = source.split(/\s+/);
|
|
143
|
+
if (parts.length < 2) {
|
|
144
|
+
error.write("A public key line needs a type and a Base64 body.");
|
|
145
|
+
summary.textContent = "";
|
|
146
|
+
result.write("");
|
|
147
|
+
empty.hidden = false;
|
|
148
|
+
result.hidden = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const [type, body] = parts;
|
|
152
|
+
if (!/^(ssh-|ecdsa-|sk-ssh-|sk-ecdsa-)/.test(type)) {
|
|
153
|
+
error.write(`"${type}" is not a known public key type.`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const blob = decodeBase64(body);
|
|
158
|
+
const hash = format === "md5" ? md5(blob) : await sha256(blob);
|
|
159
|
+
const shown = format === "md5"
|
|
160
|
+
? [...hash].map((byte) => byte.toString(16).padStart(2, "0")).join(":")
|
|
161
|
+
: toBase64(hash).replace(/=+$/, "");
|
|
162
|
+
result.write(format === "md5" ? `MD5:${shown}` : `SHA256:${shown}`);
|
|
163
|
+
result.hidden = false;
|
|
164
|
+
empty.hidden = true;
|
|
165
|
+
summary.textContent = `${type} · ${parts.slice(2).join(" ") || "no comment"} · ${blob.length} bytes`;
|
|
166
|
+
error.write("");
|
|
167
|
+
} catch (caught) {
|
|
168
|
+
error.write(caught?.message || "That key could not be read.");
|
|
169
|
+
result.write("");
|
|
170
|
+
result.hidden = true;
|
|
171
|
+
empty.hidden = false;
|
|
172
|
+
summary.textContent = "";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
key.addEventListener("input", run);
|
|
177
|
+
|
|
178
|
+
const copy = copyButton(() => result.textContent, "Copy fingerprint");
|
|
179
|
+
const clear = button("Clear", {
|
|
180
|
+
icon: "trash",
|
|
181
|
+
kind: "quiet",
|
|
182
|
+
onclick: () => {
|
|
183
|
+
key.value = "";
|
|
184
|
+
run();
|
|
185
|
+
key.focus();
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
run();
|
|
190
|
+
|
|
191
|
+
const shell = toolcard({
|
|
192
|
+
body: bench(
|
|
193
|
+
stack(
|
|
194
|
+
panel("Public key", key, "The one line that starts with ssh-ed25519 or ecdsa-sha2."),
|
|
195
|
+
el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Hash"), mode),
|
|
196
|
+
error,
|
|
197
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
198
|
+
"This reads a public key only. Paste a private key and it will be treated as an unreadable line: never paste one here.")
|
|
199
|
+
),
|
|
200
|
+
stack(summary, empty, result)
|
|
201
|
+
),
|
|
202
|
+
foot: actions(clear, copy),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
slot.append(shell.node);
|
|
206
|
+
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
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 };
|