Files
HEL/helwasm/index.html
T
Oleksandr Kozachuk fa0fd841b6 helwasm: shorten quick-gen hint (iphone); round out the About panel
Hint cut ~35% to two sentences. About 'Console' now notes form<->console share the
same catalog/session and that pb copies to the clipboard; About note mentions
home-screen install.
2026-06-10 00:13:57 +02:00

572 lines
27 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="backlink" href="../" title="Back to the Kaizenkodo site"><span class="arr" aria-hidden="true"></span> Kaizenkodo</a>
<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>
<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 (or the master) to reveal, or press <strong>Copy</strong>.
The save button steps from <strong>Store</strong> (file the entry — its name
and rules, never the password) to <strong>Correct</strong> (confirm this name +
master); once confirmed the result shows in colour, so a typo stays black.
</p>
</div>
<div>
<h4>Console</h4>
<p>
The full command line; type <code>help</code> for every command. It shares the
same catalog and session as the form above, so you can switch between them freely;
<code>pb</code> copies a command's output to the clipboard. Your list lives only in
this browser (localStorage); 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>.
End a name with <code>^folder</code> to derive it from a parent; you still type only
your one master and the whole chain is computed.
</p>
</div>
</div>
<p class="about-note">
Runs the real hel engine compiled to WebAssembly, the exact same generator as the
command-line tool. You can add it to your home screen and use it as an app. 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">
Same name + master always make the same password, computed in your browser —
your master stays in memory only, never stored or sent. <strong>Store</strong>
saves the entry (never the password); <strong>Correct</strong> saves a one-way hash.
</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" title="click to show or hide" 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 ghost small" id="saveBtn" title="Save this name in your catalog" disabled>Store</button>
<button class="btn small" id="copy">Copy</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, hel_entry } 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) ----
// The master field is the single ROOT master. hel's read_master prompts "/" for
// the root and the parent's NAME when climbing a ^parent chain; by answering only
// the "/" prompt (and "" otherwise) we force hel to climb to the root and COMPUTE
// every intermediate parent from one master — exactly what the CLI does, and the
// same for the easy form and the console below, so both give identical results.
window.hel_get_password = (prompt) =>
prompt === "/" ? (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);
// `pb` copies to the system clipboard. Fire-and-forget: it runs inside the Enter
// keypress handler, so the user-gesture requirement for clipboard write is met.
window.hel_clipboard_write = (text) => { try { navigator.clipboard.writeText(text); } catch (e) {} };
// ---- 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));
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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 ----
// Pure sugar over the same engine the console drives: it only ever runs
// hel_command(...) and DERIVES what it shows from the engine, so you can switch
// to the console at any time and keep working on the same catalog, the same
// cached master, and the same correct hashes. No private form-only state — the
// master lives only in hel's in-memory `secrets` (never written to storage);
// only the one-way "correct" hash is persisted on the device.
let lastSecret = "";
let appliedMaster = null; // last master value pushed to the engine (debounce hint)
// One progressive button. It walks the natural states, each a real engine fact:
// not in catalog -> "Store" (click: add the name)
// in catalog, master shown -> "Correct" (click: mark this name+master correct)
// verified -> "Correct ✓" (click: unmark)
// in catalog, no master -> "Stored ✓" (disabled: nothing to verify yet)
function refreshSaveBtn(name, hasResult, correct) {
const b = $("#saveBtn");
if (!b) return;
const stored = name ? !!hel_entry(name) : false;
b.classList.remove("is-correct", "is-stored");
if (!name) {
b.disabled = true; b.textContent = "Store";
b.title = "Save this name in your catalog";
} else if (!stored) {
b.disabled = false; b.textContent = "Store";
b.title = "Save this name in your catalog";
} else if (correct) {
b.disabled = false; b.textContent = "Correct ✓"; b.classList.add("is-correct");
b.title = "This name + master is verified. Click to unmark.";
} else if (hasResult) {
b.disabled = false; b.textContent = "Correct";
b.title = "Mark this name + master correct, so it shows in colour";
} else {
b.disabled = true; b.textContent = "Stored ✓"; b.classList.add("is-stored");
b.title = "In your catalog. Type the master to mark it correct.";
}
}
function quickGen(silent) {
const spec = $("#qname").value.trim();
const sec = $("#qsecret");
const lenEl = $("#qlen");
sec.classList.remove("revealed");
// 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 = spec ? hel_parse_name(spec) : "";
if (!name) {
sec.textContent = ""; sec.classList.remove("correct");
lenEl.textContent = ""; lastSecret = "";
refreshSaveBtn("", false, false);
return;
}
const master = masterEl().value;
// A new master in the field is authoritative: forget the cached chain so every
// parent recomputes from it. An empty field keeps whatever master is stored, so
// names still generate without re-typing it (the auto-`pass`). `unpass` (no arg)
// is the same command you can type in the console.
if (master !== appliedMaster) {
if (master.length) hel_command("unpass");
appliedMaster = master;
}
let out = hel_command("enc " + name);
if (/^error: name .* not found/m.test(out)) { // unstored leaf: ephemeral add/enc/rm
hel_command("add " + spec);
out = hel_command("enc " + name);
hel_command("rm " + name);
}
const pw = stripNoise(out).pop() || "";
// Colour = "is THIS name + master marked correct?" — read straight from the
// leaf's own correct-check in the enc output (per name+password, not per master),
// so changing the name (or mistyping the master) drops back to black until you
// mark that exact combination. `correct` typed in the console is reflected too.
const warnRe = new RegExp("^warning: password " + escapeRegExp(name) + " is not marked as correct$", "m");
const correct = !!pw && !warnRe.test(out);
lastSecret = pw;
sec.textContent = pw;
sec.classList.toggle("correct", correct);
lenEl.textContent = pw ? "len " + pw.length : "";
refreshSaveBtn(name, !!pw, correct);
if (!pw && !silent) toast("No output");
}
// One click advances the progressive button by one step (Store → Correct →
// unmark). Each step is the same command you could type in the console.
function saveStep() {
const b = $("#saveBtn");
if (!b || b.disabled) return;
const spec = $("#qname").value.trim();
const name = hel_parse_name(spec);
if (!name) return;
const stored = !!hel_entry(name);
if (!stored) {
hel_command("add " + spec); // file the name in the catalog
persist();
appendLine('<span class="muted"># stored ' + esc(name) + "</span>");
toast("Stored " + name);
} else if (b.classList.contains("is-correct")) {
hel_command("uncorrect " + name); // stored entry → removes the hash
toast("No longer marked correct");
} else {
hel_command("correct " + name); // stored + master present → verify (colour)
toast("Marked correct");
}
quickGen(true);
}
// ---- 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 = "";
$("#cin").focus(); // return to the command line after the overlay closes
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;
// If the name is already in the catalog, use its FULL stored spec (real
// mode/seq/date/parent), not a fresh default-extended parse of what was typed.
const name = hel_parse_name(spec);
const stored = name ? hel_entry(name) : "";
const norm = stored || hel_parse(spec);
if (norm && norm !== spec) {
$("#qname").value = norm;
quickGen(true);
}
});
$("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed"));
// Click the master field to show what you typed; click again to re-mask.
$("#master").addEventListener("click", () => $("#master").classList.toggle("revealed"));
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
$("#saveBtn").onclick = saveStep;
// pass overlay wiring
$("#passOk").onclick = submitPass;
$("#passInput").addEventListener("keydown", (e) => e.key === "Enter" && submitPass());
$("#passCancel").onclick = () => {
passPending = null;
$("#passModal").classList.remove("show");
$("#passInput").value = "";
$("#cin").focus();
};
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>