Files
HEL/helwasm/index.html
T

495 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a2540" />
<title>LesS/KEY password generator</title>
<meta name="description" content="A password manager that stores nothing and never talks to a server. Passwords are generated entirely in your browser with WebAssembly; your master password and your data never leave your device." />
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png" />
<link rel="icon" href="./assets/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" />
<link rel="manifest" href="./assets/site.webmanifest" />
<!-- Installable as a home-screen app (iOS + Android), runs standalone. -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="LesS/KEY" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!-- Fonts are self-hosted in style.css (./assets/fonts/); no external requests. -->
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header class="site">
<div class="wrap nav">
<a class="brand" href="./">
<img class="enso" src="./assets/enso.png" width="36" height="36" alt="" aria-hidden="true" />
<span class="wordmark">LesS/KEY</span>
<span class="tagline">password generator</span>
</a>
<a class="byline" href="https://kaizenkodo.no" target="_blank" rel="noopener">by Kaizenkodo</a>
<span class="nav-spacer"></span>
<button class="btn ghost" id="importBtn">Import</button>
<button class="btn ghost" id="exportBtn">Export</button>
</div>
</header>
<main class="wrap">
<!-- About / how it works (collapsible) -->
<details class="about" id="about">
<summary>
<span class="about-badge">Help</span>
<span>What is LesS/KEY &amp; how it works</span>
<span class="about-hint">click to open</span>
<span class="about-toggle" aria-hidden="true"></span>
</summary>
<div class="about-body">
<p>
LesS/KEY is a password manager that <strong>stores no passwords</strong>. It
<strong>re-generates</strong> each one on demand from your single
<em>master password</em> plus the <em>name</em> of the account. The same name and
master always produce the same password, so there is nothing secret to leak,
sync, or back up, and you only ever remember one phrase. By default each password
is six short, memorable words. That is the <em>correct horse battery staple</em> idea
from <a href="https://xkcd.com/936/" target="_blank" rel="noopener">xkcd 936</a>, built on
the classic S/KEY one-time-password scheme (<a href="https://www.rfc-editor.org/rfc/rfc2289" target="_blank" rel="noopener">RFC 2289</a>).
</p>
<p class="about-privacy">
<strong>Nothing leaves your device.</strong> Everything happens locally. Your master
password and the passwords it generates are never sent anywhere, never stored on a
server, and never logged. There is no account, no sync, and no tracking. The app is a
single page plus a WebAssembly module, with its fonts bundled in. Once it has loaded,
it makes no network requests at all.
</p>
<div class="about-grid">
<div>
<h4>Quick password</h4>
<p>
Type an account name and your master phrase. The password appears instantly,
masked. Click it to reveal, or press <strong>Copy</strong>. <strong>Store</strong>
remembers the <em>name</em> (never the password) in this browser.
</p>
</div>
<div>
<h4>Console</h4>
<p>
The full command line. Type <code>help</code> for every command. Your list of
names lives only in this browser (localStorage), never on a server. Use
<strong>Export</strong> or <strong>Import</strong> to move it yourself (for example,
to and from a Notion page).
</p>
</div>
<div>
<h4>Naming rules</h4>
<p>
After a name you can add a length and a mode. <code>R</code> is six memorable words
(the default); <code>C</code> camel; <code>H</code> hex; <code>B</code> base64;
<code>D</code> digits (<code>U…</code> = upper). For example, <code>github 20R</code>.
</p>
</div>
</div>
<p class="about-note">
Runs the real hel engine compiled to WebAssembly, the exact same generator as the
command-line tool. No server, no account, no tracking, nothing to leak.
</p>
</div>
</details>
<!-- Quick generate -->
<section class="card">
<div class="eyebrow">Generate</div>
<h2>Quick password</h2>
<p class="hint">
The same name and master always make the same password, generated live in
your browser. Nothing is saved unless you press <strong>Store</strong>, and
then only in this browser on your device, never on a server. The app never
communicates with a server at all.
</p>
<div class="fields">
<div class="field">
<label for="qname">Name (+ optional rules)</label>
<input type="text" id="qname" placeholder="github or github 20R 99 2024-01-01" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" autofocus />
</div>
<div class="field">
<label for="master">Master password</label>
<input type="text" id="master" class="mask" placeholder="your master phrase" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" />
</div>
</div>
<div class="result">
<span class="tag">result</span>
<span class="secret" id="qsecret" title="click to reveal"></span>
<span class="len" id="qlen"></span>
<span class="actions">
<button class="btn small" id="copy">Copy</button>
<button class="btn ghost small" id="store">Store</button>
</span>
</div>
</section>
<!-- Console -->
<section class="card">
<div class="eyebrow">Console</div>
<h2>All commands</h2>
<p class="hint">
The full hel command line: <code>ls</code>, <code>add</code>,
<code>enc</code>, <code>gen</code>, <code>comment</code>,
<code>correct</code>, <code>help</code>, … Your catalog persists in this
browser (localStorage). Generated passwords are masked; click to reveal.
</p>
<div class="console-out" id="cout"></div>
<div class="console-in">
<span class="prompt">&gt;</span>
<input type="text" id="cin" placeholder="type a command and press Enter" autocomplete="off" spellcheck="false" />
</div>
</section>
</main>
<footer class="foot wrap">
A free tool by <a href="https://kaizenkodo.no" target="_blank" rel="noopener">Kaizenkodo</a> (kaizenkodo.no).
Runs the real hel core compiled to WebAssembly, the same generator as the CLI.
Everything runs in your browser; nothing you type is ever sent to a server.
</footer>
<div class="toast" id="toast"></div>
<!-- Import modal -->
<div class="modal-bg" id="importModal">
<div class="modal">
<h3>Import catalog</h3>
<p class="hint">Paste your catalog (e.g. the text of the Notion page). Existing entries with the same name are replaced.</p>
<textarea id="importText" placeholder="add ..."></textarea>
<div class="row">
<button class="btn" id="importDo">Import</button>
<button class="btn ghost" id="importCancel">Cancel</button>
</div>
</div>
</div>
<!-- Export modal -->
<div class="modal-bg" id="exportModal">
<div class="modal">
<h3>Export catalog</h3>
<p class="hint">Copy this and paste it back into your Notion page.</p>
<textarea id="exportText" readonly></textarea>
<div class="row">
<button class="btn" id="exportCopy">Copy</button>
<button class="btn ghost" id="exportClose">Close</button>
</div>
</div>
</div>
<!-- Password prompt modal (for the `pass` command) -->
<div class="modal-bg" id="passModal">
<div class="modal">
<h3 id="passTitle">Set password</h3>
<p class="hint">Cache a master/parent password for this name (used by <code>enc</code> in its subtree). Kept in memory only.</p>
<div class="field">
<label for="passInput">Password</label>
<input type="text" id="passInput" class="mask" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" />
</div>
<div class="row">
<button class="btn" id="passOk">Set</button>
<button class="btn ghost" id="passCancel">Cancel</button>
</div>
</div>
</div>
<script type="module">
import init, { hel_init, hel_command, hel_load_script, hel_parse, hel_parse_name } from "./pkg/helwasm.js";
const $ = (s) => document.querySelector(s);
const masterEl = () => document.getElementById("master");
const CATALOG_KEY = "hel_catalog";
// ---- host imports (wasm calls these by bare name → must be globals) ----
window.hel_get_password = () => (masterEl() ? masterEl().value : "") || "";
window.hel_rnd_range = (s, e) => {
if (e <= s) return s;
const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296;
return s + Math.floor(r * (e - s));
};
window.hel_storage_get = (k) => localStorage.getItem("hel:" + k);
window.hel_storage_set = (k, v) => localStorage.setItem("hel:" + k, v);
// ---- helpers ----
const persist = () => hel_command("save " + CATALOG_KEY);
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const stripNoise = (out) => out.split("\n").filter((l) => l && !/^(warning|error):/.test(l));
let toastT;
function toast(msg) {
const t = $("#toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastT);
toastT = setTimeout(() => t.classList.remove("show"), 1600);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast("Copied");
} catch {
toast("Copy failed");
}
}
function secretSpan(text) {
const s = document.createElement("span");
s.className = "secret";
s.textContent = text;
s.title = "click to reveal";
s.addEventListener("click", () => s.classList.toggle("revealed"));
return s;
}
// ---- quick generate (live; stateless unless Store) ----
let lastSecret = "";
function quickGen(silent) {
const spec = $("#qname").value.trim();
const master = masterEl().value;
const sec = $("#qsecret");
const lenEl = $("#qlen");
sec.classList.remove("revealed");
if (!spec || !master) {
sec.textContent = "";
lenEl.textContent = "";
lastSecret = "";
return;
}
// The entry name is NOT always the first token; a leading prefix
// (like `*P0 test1 …`) means the name is the next word. Parse it.
const name = hel_parse_name(spec);
if (!name) {
sec.textContent = "";
lenEl.textContent = "";
lastSecret = "";
return;
}
// hel caches the root master in secrets["/"]; clear it so the live result
// always reflects the current master field (not a stale cached value).
hel_command("unpass /");
let out = hel_command("enc " + name);
if (/not found/.test(out)) {
hel_command("add " + spec);
out = hel_command("enc " + name);
hel_command("rm " + name);
}
hel_command("unpass /");
const pw = stripNoise(out).pop() || "";
lastSecret = pw;
sec.textContent = pw;
lenEl.textContent = pw ? "len " + pw.length : "";
if (!pw && !silent) toast("No output");
}
function quickStore() {
const spec = $("#qname").value.trim();
if (!spec) return toast("Enter a name");
const name = hel_parse_name(spec) || spec.split(/\s+/)[0];
const out = hel_command("add " + spec);
persist();
appendLine('<span class="muted"># stored ' + esc(name) + "</span>");
toast(/^(error|warning)/m.test(out) ? out.split("\n")[0] : "Stored " + name);
}
// ---- console ----
// Scroll to the bottom after layout (rAF) so the view always follows output.
function scrollBottom() {
const out = $("#cout");
requestAnimationFrame(() => { out.scrollTop = out.scrollHeight; });
}
function appendLine(html, cls) {
const out = $("#cout");
const div = document.createElement("div");
if (cls) div.className = cls;
div.innerHTML = html;
out.appendChild(div);
scrollBottom();
}
function appendNode(node) {
const out = $("#cout");
const div = document.createElement("div");
div.appendChild(node);
out.appendChild(div);
scrollBottom();
}
// gen data line: {:>3} {:>36} {:>4} {} → key(0..3) ' ' pass(4..40) ' ' len(41..45) ' ' name(46..)
// Return the column split so we mask ONLY the password (never the header).
function parseGenLine(line) {
if (line.length < 46) return null;
const lenStr = line.slice(41, 45).trim();
if (!/^\d+$/.test(lenStr)) return null; // header "Len" → not numeric → plain
const len = parseInt(lenStr, 10);
if (len <= 0 || len > 36) return null;
return { prefix: line.slice(0, 40 - len), secret: line.slice(40 - len, 40), suffix: line.slice(40) };
}
function consoleRun(cmd) {
appendLine('<span class="cmd">&gt; ' + esc(cmd) + "</span>");
const out = hel_command(cmd);
persist();
if (!out) return;
const verb = cmd.trim().split(/\s+/)[0];
const isGen = /^gen\d*$/.test(verb); // gen, gen3, gen10, …
const isEnc = verb === "enc";
for (const line of out.split("\n")) {
if (/^(warning|error):/.test(line)) {
appendLine(esc(line), "err");
} else if (isGen) {
const p = parseGenLine(line);
if (!p) { appendLine(esc(line) || "&nbsp;"); continue; }
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode(p.prefix));
frag.appendChild(secretSpan(p.secret));
frag.appendChild(document.createTextNode(p.suffix));
appendNode(frag);
} else if (isEnc && line.trim() && !line.startsWith("add ")) {
appendNode(secretSpan(line));
} else {
appendLine(esc(line) || "&nbsp;");
}
}
}
// ---- pass overlay (a browser can't block for input, so collect the
// password in a modal, then run the long form `pass <name> <pw>`) ----
let passPending = null;
function openPassOverlay(name) {
passPending = name;
$("#passTitle").textContent = 'Password for "' + name + '"';
$("#passInput").value = "";
$("#passModal").classList.add("show");
setTimeout(() => $("#passInput").focus(), 30);
}
function submitPass() {
const name = passPending;
passPending = null;
const pw = $("#passInput").value;
$("#passModal").classList.remove("show");
$("#passInput").value = "";
if (name == null) return;
appendLine('<span class="cmd">&gt; pass ' + esc(name) + "</span>");
if (!pw) {
appendLine('<span class="muted"># cancelled</span>');
return;
}
const out = hel_command("pass " + name + " " + pw); // long form, no prompt
persist();
if (out && out.trim()) {
for (const l of out.split("\n")) appendLine(esc(l), /^(warning|error):/.test(l) ? "err" : null);
}
appendLine('<span class="muted"># password cached for ' + esc(name) + "</span>");
}
// ---- boot ----
async function boot() {
await init();
hel_init();
if (localStorage.getItem("hel:" + CATALOG_KEY)) hel_command("source " + CATALOG_KEY);
// remember the About panel's open/closed state across visits
const about = $("#about");
const aboutState = localStorage.getItem("hel:_about_open");
if (aboutState !== null) about.open = aboutState === "1";
about.addEventListener("toggle", () => localStorage.setItem("hel:_about_open", about.open ? "1" : "0"));
// live generation
let liveT;
const live = () => {
clearTimeout(liveT);
liveT = setTimeout(() => quickGen(true), 70);
};
$("#qname").addEventListener("input", live);
$("#master").addEventListener("input", live);
$("#qname").addEventListener("keydown", (e) => e.key === "Enter" && quickGen(false));
// On blur, rewrite the name field to the canonical form hel parsed it as
// (name + mode + seq + date + comment), so you see exactly what was used.
$("#qname").addEventListener("blur", () => {
const spec = $("#qname").value.trim();
if (!spec) return;
const norm = hel_parse(spec);
if (norm && norm !== spec) {
$("#qname").value = norm;
quickGen(true);
}
});
$("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed"));
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
$("#store").onclick = quickStore;
// pass overlay wiring
$("#passOk").onclick = submitPass;
$("#passInput").addEventListener("keydown", (e) => e.key === "Enter" && submitPass());
$("#passCancel").onclick = () => {
passPending = null;
$("#passModal").classList.remove("show");
$("#passInput").value = "";
};
const ci = $("#cin");
// command history with Up/Down (persisted across reloads)
const HKEY = "_console_history";
const history = JSON.parse(localStorage.getItem("hel:" + HKEY) || "[]");
let hi = history.length; // pointer; === length means "current draft"
let draft = "";
const moveCaretEnd = () => requestAnimationFrame(() => ci.setSelectionRange(ci.value.length, ci.value.length));
ci.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const v = ci.value.trim();
ci.value = "";
if (!v) return;
if (history[history.length - 1] !== v) history.push(v);
if (history.length > 300) history.shift();
localStorage.setItem("hel:" + HKEY, JSON.stringify(history));
hi = history.length;
draft = "";
const pm = v.match(/^pass\s+(\S+)\s*$/); // `pass <name>` → ask in an overlay
if (pm) { openPassOverlay(pm[1]); return; }
consoleRun(v);
} else if (e.key === "ArrowUp") {
if (hi > 0) {
if (hi === history.length) draft = ci.value;
ci.value = history[--hi];
e.preventDefault();
moveCaretEnd();
}
} else if (e.key === "ArrowDown") {
if (hi < history.length) {
hi++;
ci.value = hi === history.length ? draft : history[hi];
e.preventDefault();
moveCaretEnd();
}
}
});
$("#importBtn").onclick = () => $("#importModal").classList.add("show");
$("#importCancel").onclick = () => $("#importModal").classList.remove("show");
$("#importDo").onclick = () => {
const txt = $("#importText").value;
if (txt.trim()) {
hel_load_script(txt);
persist();
toast("Imported");
appendLine('<span class="muted"># imported catalog</span>');
}
$("#importModal").classList.remove("show");
$("#importText").value = "";
};
$("#exportBtn").onclick = () => {
$("#exportText").value = hel_command("dump"); // `dump` prints the catalog
$("#exportModal").classList.add("show");
};
$("#exportClose").onclick = () => $("#exportModal").classList.remove("show");
$("#exportCopy").onclick = () => copyText($("#exportText").value);
// click backdrop closes modals
document.querySelectorAll(".modal-bg").forEach((bg) =>
bg.addEventListener("click", (e) => e.target === bg && bg.classList.remove("show"))
);
appendLine('<span class="muted"># LesS/KEY ready. Runs entirely in your browser (WASM); nothing is sent to a server. Try: help</span>');
}
boot();
</script>
</body>
</html>