reze: Add Node.js password generator with memorable and random modes

Public password generator with no auth and no npm dependencies.

- Server-side generation using crypto.randomInt; two levels: diceware-style
  memorable passphrases (42abacus-91abdomen) and fully random characters.
- Configurable word list (EFF Large / EFF Short), digits per word, separators,
  capitalization, length, character class toggles, custom symbols and
  ambiguous-character exclusion.
- Light/dark theme matching druid520/site, entropy estimate and strength bar,
  clipboard copy, options persisted in localStorage.
- Dockerfile for Dokploy plus a Woodpecker deploy hook on trunk.
This commit is contained in:
2026-10-21 09:01:42 +00:00
parent aa56937a20
commit 58f585b691
19 changed files with 10974 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.git
npm-debug.log
test
*.md
.woodpecker.yml
+4
View File
@@ -0,0 +1,4 @@
node_modules
npm-debug.log*
.DS_Store
*.log
+15
View File
@@ -0,0 +1,15 @@
when:
- event: push
branch: trunk
steps:
deploy:
image: curlimages/curl:latest
# Needs Dokploy on the internal network; allowed because an admin marked this repo "trusted network".
# Pipelines otherwise run on the isolated ci-net (internet + git.kg only).
network_mode: dokploy-network
environment:
DEPLOY_URL:
from_secret: DEPLOY_URL
commands:
- 'curl -fsS -X POST "$${DEPLOY_URL}" -H "Content-Length: 0"'
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY server.js ./
COPY src ./src
COPY public ./public
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1
CMD ["node","server.js"]
+185
View File
@@ -0,0 +1,185 @@
# reze-test
A small, dependency-free password generator. It has two modes:
- **Memorable** — diceware-style passphrases shaped like `[num][word]-[num][word]-…`, e.g. `42abacus-91abdomen`.
- **Random** — fully random characters from a configurable character set.
Everything is generated server-side with Node's CSPRNG (`crypto.randomInt`). There is no
account, no login, no database, and nothing you generate is stored or logged.
## Features
- Two randomization levels: memorable passphrases and fully random passwords.
- EFF word lists (7776-word and 1296-word) selectable per request.
- Configurable digits per word, digit separator, group separator and capitalization.
- Configurable length, character classes, custom symbol set, ambiguous-character exclusion
and "at least one of each selected set".
- Generate up to 10 passwords at once, each with an entropy estimate and a strength bar.
- One-click copy with a clipboard fallback.
- Light/dark theme with a system-theme fallback, persisted in `localStorage`.
- Options persisted in `localStorage`; generated passwords are never persisted.
- No npm dependencies at all — Node.js built-ins only.
## Requirements
- Node.js 22 or newer (ESM, `"type": "module"`).
## Run locally
```sh
node server.js
```
The server listens on `0.0.0.0:3000` by default. Override the port with `PORT`:
```sh
PORT=8080 node server.js
```
Open <http://localhost:3000>.
Or via the POSIX sh helper, which defaults `PORT` to 3000:
```sh
sh scripts/run.sh
PORT=8080 sh scripts/run.sh
```
## Tests
```sh
node --test
```
## API
All API responses are JSON. Every response carries the security headers
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
`X-Frame-Options: DENY` and a strict `Content-Security-Policy`.
### `GET /healthz`
Liveness probe used by the Docker `HEALTHCHECK`.
```sh
curl -s localhost:3000/healthz
```
```json
{"status":"ok"}
```
### `GET /api/meta`
Describes the available word lists.
```sh
curl -s localhost:3000/api/meta
```
```json
{"lists":[{"id":"eff-large","name":"EFF Large Word List","size":7776},{"id":"eff-short","name":"EFF Short Word List (2.0)","size":1296}]}
```
### `POST /api/generate`
Body is a JSON object. `mode` selects the algorithm; `count` (1–10, default 1) is the
number of passwords to return. Unknown modes, malformed JSON and out-of-range options
are rejected with `400` and a JSON `{"error": "…"}` body. Request bodies are limited to
64 KB.
Memorable options: `words` (2–10), `digits` (0–4), `digitSeparator` (`''` or `'-'`),
`separator` (`'-'`, `'_'`, `'.'`, `' '` or `''`), `capitalization`
(`'none'`, `'title'`, `'upper'`), `list` (`'eff-large'`, `'eff-short'`).
```sh
curl -s -X POST localhost:3000/api/generate \
-H 'Content-Type: application/json' \
-d '{"mode":"memorable","words":2,"digits":2,"separator":"-","digitSeparator":"","capitalization":"title","list":"eff-large","count":2}'
```
```json
{"mode":"memorable","count":2,"passwords":[{"value":"07Sample-29Breach","mode":"memorable","entropy":39.1},{"value":"69Reassign-85Probation","mode":"memorable","entropy":39.1}],"entropy":39.1}
```
Random options: `length` (8–64), `useLower`, `useUpper`, `useDigits`, `useSymbols`,
`customSymbols` (replaces the default symbol set when non-empty), `excludeAmbiguous`
(removes `0 O 1 l I |`) and `requireEach`.
```sh
curl -s -X POST localhost:3000/api/generate \
-H 'Content-Type: application/json' \
-d '{"mode":"random","length":24,"useLower":true,"useUpper":true,"useDigits":true,"useSymbols":true,"requireEach":true,"count":1}'
```
```json
{"mode":"random","count":1,"passwords":[{"value":"3V#P@bQ2C4c3;=igw}E(EpnG","mode":"random","entropy":154.6}],"entropy":154.6}
```
### Entropy
- Random mode: `length * log2(poolSize)`, where `poolSize` is the size of the union of all
enabled character classes.
- Memorable mode: `words * log2(listSize) + words * digits * log2(10)`. Separators and the
capitalization scheme add no entropy.
### Other paths
Unknown `/api/*` paths return a JSON `404`. Unknown page paths return
`public/404.html` with HTTP `404`. Only `GET` and `HEAD` are accepted for static files.
## Word lists
| id | list | size |
| --- | --- | --- |
| `eff-large` | EFF Large Word List | 7776 |
| `eff-short` | EFF Short Word List (2.0) | 1296 |
Both files live in `public/words/` and are loaded once at startup.
- Source: [Electronic Frontier Foundation](https://www.eff.org/dice) — the
[EFF Diceware wordlists](https://www.eff.org/dice-roll-the-dice).
- Licence: Creative Commons Attribution 3.0 Unported (CC BY 3.0),
<https://www.eff.org/copyright>.
The lists contain a few hyphenated entries (`drop-down`, `felt-tip`, `t-shirt`, `yo-yo`),
which are used verbatim.
## Environment variables
| variable | default | description |
| --- | --- | --- |
| `PORT` | `3000` | TCP port the HTTP server binds to on `0.0.0.0`. |
| `NODE_ENV` | — | Set to `production` in the image. |
## Docker
```sh
docker build -t reze-test .
docker run --rm -p 3000:3000 reze-test
```
Or with compose:
```sh
docker compose up --build -d
```
The image is a single stage based on `node:22-alpine`, runs as the non-root `node` user
and ships a `HEALTHCHECK` that polls `http://127.0.0.1:3000/healthz` with `wget`.
## Deployment (Dokploy)
- Repository: `druid520/reze-test` on Gitea (git.kg.boson.party).
- Branch: `trunk`.
- Build type: Dockerfile (or "Nixpacks" — the app needs nothing but Node).
- Environment variable: `PORT=3000`.
- Exposed port: `3000`.
- CI hook: add the repository secret `DEPLOY_URL` (the Dokploy deploy webhook URL).
`.woodpecker.yml` posts to it on every push to `trunk` so Dokploy rebuilds and
redeploys the service.
## License
MIT for the code. The bundled word lists are CC BY 3.0, © Electronic Frontier Foundation.
+9
View File
@@ -0,0 +1,9 @@
services:
app:
build: .
image: reze-test:latest
ports:
- "3000:3000"
environment:
PORT: 3000
restart: unless-stopped
+15
View File
@@ -0,0 +1,15 @@
{
"name": "reze-test",
"version": "1.0.0",
"private": true,
"description": "Password generator web app: diceware-style passphrases and fully random passwords.",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"license": "MIT"
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found · reze-test</title>
<link rel="stylesheet" href="/style.css">
<script src="/theme.js"></script>
</head>
<body>
<main class="error">
<div class="topbar"><button id="theme-toggle" class="theme-toggle" type="button" hidden>Dark mode</button></div>
<h2>Error 404</h2>
<h1>Not found</h1>
<p>There's nothing at this address.</p>
<a href="/">Back to reze-test</a>
</main>
</body>
</html>
+379
View File
@@ -0,0 +1,379 @@
(function () {
'use strict';
var STORAGE_KEY = 'opts';
var DEFAULTS = {
mode: 'memorable',
words: 4,
digits: 2,
digitSeparator: '',
separator: '-',
capitalization: 'none',
list: 'eff-large',
length: 20,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: false,
customSymbols: '',
excludeAmbiguous: false,
requireEach: true,
count: 1
};
var FIELDS = [
{ key: 'words', id: 'words', type: 'int', min: 2, max: 10 },
{ key: 'digits', id: 'digits', type: 'int', min: 0, max: 4 },
{ key: 'digitSeparator', id: 'digitSeparator', type: 'str' },
{ key: 'separator', id: 'separator', type: 'str' },
{ key: 'capitalization', id: 'capitalization', type: 'str' },
{ key: 'list', id: 'list', type: 'str' },
{ key: 'length', id: 'length', type: 'int', min: 8, max: 64 },
{ key: 'count', id: 'count', type: 'int', min: 1, max: 10 },
{ key: 'customSymbols', id: 'customSymbols', type: 'str' },
{ key: 'useLower', id: 'useLower', type: 'bool' },
{ key: 'useUpper', id: 'useUpper', type: 'bool' },
{ key: 'useDigits', id: 'useDigits', type: 'bool' },
{ key: 'useSymbols', id: 'useSymbols', type: 'bool' },
{ key: 'excludeAmbiguous', id: 'excludeAmbiguous', type: 'bool' },
{ key: 'requireEach', id: 'requireEach', type: 'bool' }
];
var STRENGTH_LABELS = ['very weak', 'weak', 'fair', 'strong', 'very strong'];
var STRENGTH_THRESHOLDS = [28, 40, 60, 80];
function byId(id) {
return document.getElementById(id);
}
function clampInt(value, fallback, min, max) {
var n = parseInt(value, 10);
if (isNaN(n)) return fallback;
if (n < min) return min;
if (n > max) return max;
return n;
}
function loadState() {
var state = {};
var key;
for (key in DEFAULTS) {
if (Object.prototype.hasOwnProperty.call(DEFAULTS, key)) state[key] = DEFAULTS[key];
}
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
var saved = JSON.parse(raw);
if (saved && typeof saved === 'object') {
for (key in saved) {
if (Object.prototype.hasOwnProperty.call(state, key)) state[key] = saved[key];
}
}
}
} catch (e) {}
return state;
}
function saveState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {}
}
function readState() {
var state = {};
var i;
var field;
var el;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
el = byId(field.id);
if (!el) continue;
if (field.type === 'bool') {
state[field.key] = el.checked;
} else if (field.type === 'int') {
state[field.key] = clampInt(el.value, DEFAULTS[field.key], field.min, field.max);
} else {
state[field.key] = el.value;
}
}
var checked = document.querySelector('input[name="mode"]:checked');
state.mode = checked ? checked.value : DEFAULTS.mode;
return state;
}
function applyState(state) {
var i;
var field;
var el;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
el = byId(field.id);
if (!el) continue;
if (field.type === 'bool') {
el.checked = Boolean(state[field.key]);
} else if (field.type === 'int') {
el.value = clampInt(state[field.key], DEFAULTS[field.key], field.min, field.max);
} else {
el.value = state[field.key] === undefined || state[field.key] === null ? '' : state[field.key];
}
}
var radios = document.querySelectorAll('input[name="mode"]');
for (i = 0; i < radios.length; i += 1) {
radios[i].checked = radios[i].value === state.mode;
}
}
function updatePanels(state) {
var memorable = byId('panel-memorable');
var random = byId('panel-random');
if (memorable) memorable.hidden = state.mode !== 'memorable';
if (random) random.hidden = state.mode !== 'random';
}
function updateLengthOut() {
var length = byId('length');
var out = byId('length-out');
if (length && out) out.textContent = length.value;
}
function strengthLevel(bits) {
var i;
for (i = 0; i < STRENGTH_THRESHOLDS.length; i += 1) {
if (bits < STRENGTH_THRESHOLDS[i]) return i;
}
return STRENGTH_LABELS.length - 1;
}
function buildStrength(bits) {
var level = strengthLevel(bits);
var wrap = document.createElement('span');
wrap.className = 'strength';
wrap.setAttribute('role', 'img');
wrap.setAttribute('aria-label', 'strength: ' + STRENGTH_LABELS[level]);
var i;
for (i = 0; i < STRENGTH_LABELS.length; i += 1) {
var seg = document.createElement('i');
if (i <= level) seg.className = 'on';
wrap.appendChild(seg);
}
return wrap;
}
function showError(message) {
var box = byId('error');
if (!box) return;
if (message) {
box.textContent = message;
box.hidden = false;
} else {
box.textContent = '';
box.hidden = true;
}
}
function copyWithFallback(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', 'readonly');
area.style.position = 'absolute';
area.style.left = '-9999px';
document.body.appendChild(area);
area.select();
var ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
document.body.removeChild(area);
if (ok) {
resolve();
} else {
reject(new Error('copy failed'));
}
});
}
function copyPassword(value, button) {
var original = button.getAttribute('data-label') || 'Copy';
copyWithFallback(value).then(function () {
button.textContent = 'Copied';
}, function () {
button.textContent = 'Failed';
}).then(function () {
window.setTimeout(function () {
button.textContent = original;
}, 1200);
});
}
function renderPasswords(data) {
var container = byId('results');
if (!container) return;
container.textContent = '';
var list = (data && data.passwords) || [];
for (let i = 0; i < list.length; i += 1) {
const item = list[i];
const row = document.createElement('div');
row.className = 'pw';
const value = document.createElement('pre');
value.className = 'pw-value';
value.textContent = item.value;
const meta = document.createElement('div');
meta.className = 'pw-meta';
const entropy = document.createElement('span');
entropy.className = 'pw-entropy';
entropy.textContent = item.entropy + ' bits';
const level = strengthLevel(item.entropy);
const label = document.createElement('span');
label.className = 'pw-strength-label';
label.textContent = STRENGTH_LABELS[level];
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'button copy';
copy.textContent = 'Copy';
copy.setAttribute('data-label', 'Copy');
copy.setAttribute('aria-label', 'Copy password');
copy.addEventListener('click', function () {
copyPassword(item.value, copy);
});
meta.appendChild(entropy);
meta.appendChild(buildStrength(item.entropy));
meta.appendChild(label);
meta.appendChild(copy);
row.appendChild(value);
row.appendChild(meta);
container.appendChild(row);
}
}
function payloadFor(state) {
if (state.mode === 'random') {
return {
mode: 'random',
length: state.length,
useLower: state.useLower,
useUpper: state.useUpper,
useDigits: state.useDigits,
useSymbols: state.useSymbols,
customSymbols: state.customSymbols,
excludeAmbiguous: state.excludeAmbiguous,
requireEach: state.requireEach,
count: state.count
};
}
return {
mode: 'memorable',
words: state.words,
digits: state.digits,
digitSeparator: state.digitSeparator,
separator: state.separator,
capitalization: state.capitalization,
list: state.list,
count: state.count
};
}
function generate() {
var state = readState();
saveState(state);
showError('');
fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadFor(state))
}).then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error(data && data.error ? data.error : 'request failed with status ' + response.status);
}
return data;
});
}).then(function (data) {
renderPasswords(data);
}, function (err) {
showError(err && err.message ? err.message : 'request failed');
});
}
function loadMeta() {
fetch('/api/meta', { headers: { Accept: 'application/json' } }).then(function (response) {
return response.json();
}).then(function (data) {
var lists = (data && data.lists) || [];
var i;
for (i = 0; i < lists.length; i += 1) {
var option = document.querySelector('#list option[value="' + lists[i].id + '"]');
if (option) option.textContent = lists[i].name + ' (' + lists[i].size + ' words)';
}
}, function () {});
}
function init() {
var state = loadState();
applyState(state);
updatePanels(state);
updateLengthOut();
var i;
var field;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
(function (f) {
var el = byId(f.id);
if (!el) return;
var handler = function () {
var current = readState();
saveState(current);
updatePanels(current);
updateLengthOut();
};
el.addEventListener('change', handler);
el.addEventListener('input', handler);
})(field);
}
var radios = document.querySelectorAll('input[name="mode"]');
for (i = 0; i < radios.length; i += 1) {
radios[i].addEventListener('change', function () {
var current = readState();
saveState(current);
updatePanels(current);
});
}
var generateBtn = byId('generate');
if (generateBtn) generateBtn.addEventListener('click', generate);
var regenerateBtn = byId('regenerate');
if (regenerateBtn) regenerateBtn.addEventListener('click', generate);
loadMeta();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reze-test</title>
<meta name="description" content="Password generator: diceware-style passphrases or fully random characters. Nothing is stored.">
<link rel="stylesheet" href="/style.css">
<script src="/theme.js"></script>
</head>
<body>
<main>
<div class="topbar"><button id="theme-toggle" class="theme-toggle" type="button" hidden>Dark mode</button></div>
<h1>reze-test</h1>
<p class="lead">Password generator. Diceware-style passphrases or fully random characters &mdash; generated locally on the server with a CSPRNG. Nothing you generate is stored.</p>
<h2>Mode</h2>
<div class="radios">
<label class="radio"><input type="radio" name="mode" value="memorable" checked> Memorable</label>
<label class="radio"><input type="radio" name="mode" value="random"> Random</label>
</div>
<section id="panel-memorable">
<h2>Passphrase</h2>
<div class="field">
<label for="words">Words per passphrase</label>
<input type="number" id="words" min="2" max="10" step="1" value="4">
</div>
<div class="field">
<label for="digits">Digits per word</label>
<input type="number" id="digits" min="0" max="4" step="1" value="2">
</div>
<div class="field">
<label for="digitSeparator">Digit separator</label>
<select id="digitSeparator">
<option value="" selected>none</option>
<option value="-">- (hyphen)</option>
</select>
</div>
<div class="field">
<label for="separator">Group separator</label>
<select id="separator">
<option value="-">- (hyphen)</option>
<option value="_">_ (underscore)</option>
<option value=".">. (dot)</option>
<option value=" ">space</option>
<option value="">none</option>
</select>
</div>
<div class="field">
<label for="capitalization">Capitalization</label>
<select id="capitalization">
<option value="none" selected>none</option>
<option value="title">Title case</option>
<option value="upper">UPPERCASE</option>
</select>
</div>
<div class="field">
<label for="list">Word set</label>
<select id="list">
<option value="eff-large">EFF Large Word List</option>
<option value="eff-short">EFF Short Word List (2.0)</option>
</select>
</div>
</section>
<section id="panel-random" hidden>
<h2>Random</h2>
<div class="field">
<label for="length">Length</label>
<div class="range-row">
<input type="range" id="length" min="8" max="64" step="1" value="20">
<output id="length-out" for="length">20</output>
</div>
</div>
<div class="checks">
<label class="check"><input type="checkbox" id="useLower" checked> lowercase</label>
<label class="check"><input type="checkbox" id="useUpper" checked> uppercase</label>
<label class="check"><input type="checkbox" id="useDigits" checked> digits</label>
<label class="check"><input type="checkbox" id="useSymbols"> symbols</label>
<label class="check"><input type="checkbox" id="excludeAmbiguous"> exclude ambiguous characters</label>
<label class="check"><input type="checkbox" id="requireEach" checked> require at least one of each selected set</label>
</div>
<div class="field">
<label for="customSymbols">Custom symbols</label>
<input type="text" id="customSymbols" value="" placeholder="!@#$%^&amp;*()-_=+[]{};:,.&lt;&gt;?" autocomplete="off" spellcheck="false">
</div>
</section>
<h2>Output</h2>
<div class="field">
<label for="count">How many passwords</label>
<input type="number" id="count" min="1" max="10" step="1" value="1">
</div>
<div class="actions">
<button id="generate" class="button primary" type="button">Generate</button>
<button id="regenerate" class="button" type="button">Regenerate</button>
</div>
<p id="error" class="error-line" hidden></p>
<div id="results"></div>
</main>
<script src="/app.js"></script>
</body>
</html>
+240
View File
@@ -0,0 +1,240 @@
:root {
--bg: #fafafa;
--fg: #1a1a1a;
--muted: #666;
--line: #ddd;
--code-bg: #f0f0f0;
--link: #0b5cad;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #111;
--fg: #e6e6e6;
--muted: #999;
--line: #333;
--code-bg: #1c1c1c;
--link: #c4a7ff;
}
}
:root[data-theme="dark"] {
--bg: #111;
--fg: #e6e6e6;
--muted: #999;
--line: #333;
--code-bg: #1c1c1c;
--link: #c4a7ff;
color-scheme: dark;
}
:root[data-theme="light"] { color-scheme: light; }
[hidden] { display: none !important; }
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main {
max-width: 42rem;
margin: 0 auto;
padding: 3rem 1.25rem;
}
h1 { font-size: 1.75rem; margin: 0 0 2rem; }
h2 {
font-size: .8rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
margin: 2.5rem 0 .75rem;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar { display: flex; justify-content: flex-end; margin: -1.5rem 0 1.5rem; }
.theme-toggle {
padding: .25rem .7rem;
background: transparent;
color: var(--fg);
border: 1px solid var(--line);
border-radius: 6px;
font-family: inherit;
font-size: .85rem;
line-height: 1.4;
cursor: pointer;
}
.theme-toggle:hover { border-color: var(--muted); }
.theme-toggle:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: .5rem 1.5rem;
margin: 0;
}
dt { color: var(--muted); }
dd { margin: 0; overflow-wrap: anywhere; }
.key { margin: 0 0 1.25rem; }
.key p { margin: 0 0 .35rem; color: var(--muted); font-size: .9rem; }
pre {
margin: 0;
padding: .75rem 1rem;
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 6px;
overflow-x: auto;
font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
main.error h2 { margin-top: 0; }
main.error h1 { margin-bottom: .75rem; }
main.error p { color: var(--muted); margin: 0 0 2rem; }
.lead { margin: -1.5rem 0 0; color: var(--muted); }
.radios { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; }
.radio,
.check {
display: flex;
align-items: center;
gap: .5rem;
font-size: .95rem;
}
.radio input,
.check input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: var(--link);
}
.checks { display: grid; gap: .4rem; margin-bottom: 1rem; }
.field { margin-bottom: 1rem; }
.field > label {
display: block;
margin-bottom: .3rem;
color: var(--muted);
font-size: .9rem;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
max-width: 24rem;
padding: .4rem .55rem;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 6px;
font: inherit;
font-size: .95rem;
}
select { cursor: pointer; }
input[type="range"] {
width: 100%;
max-width: 24rem;
accent-color: var(--link);
}
.range-row { display: flex; align-items: center; gap: .75rem; }
.range-row output {
min-width: 2.5rem;
color: var(--muted);
font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid var(--link);
outline-offset: 2px;
}
.button {
padding: .45rem .9rem;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 6px;
font-family: inherit;
font-size: .9rem;
line-height: 1.4;
cursor: pointer;
}
.button:hover { border-color: var(--muted); }
.button.primary {
background: var(--link);
color: #fff;
border-color: var(--link);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .button.primary { color: #111; }
}
:root[data-theme="dark"] .button.primary { color: #111; }
.actions { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.25rem 0; }
.error-line {
margin: 0 0 1.25rem;
padding-left: .6rem;
border-left: 2px solid var(--link);
color: var(--fg);
font-size: .9rem;
}
.pw { margin-bottom: 1.5rem; }
.pw:last-child { margin-bottom: 0; }
.pw-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .5rem .9rem;
margin-top: .5rem;
color: var(--muted);
font-size: .85rem;
}
.pw-entropy { font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.strength { display: inline-flex; gap: 3px; }
.strength i {
display: inline-block;
width: 14px;
height: 6px;
border-radius: 3px;
background: var(--line);
}
.strength i.on { background: var(--link); }
.pw-strength-label { text-transform: capitalize; }
@media (max-width: 30rem) {
dl { grid-template-columns: 1fr; gap: 0; }
dd { margin-bottom: .75rem; }
input[type="text"],
input[type="number"],
select,
input[type="range"] { max-width: 100%; }
.actions .button { flex: 1 1 auto; }
}
+32
View File
@@ -0,0 +1,32 @@
(function () {
var root = document.documentElement;
var media = window.matchMedia('(prefers-color-scheme: dark)');
try {
var saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') root.dataset.theme = saved;
} catch (e) {}
function current() {
return root.dataset.theme || (media.matches ? 'dark' : 'light');
}
function label(btn) {
var next = current() === 'dark' ? 'light' : 'dark';
btn.textContent = next === 'dark' ? 'Dark mode' : 'Light mode';
btn.setAttribute('aria-label', 'Switch to ' + next + ' mode');
}
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
label(btn);
btn.hidden = false;
btn.addEventListener('click', function () {
root.dataset.theme = current() === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('theme', root.dataset.theme); } catch (e) {}
label(btn);
});
media.addEventListener('change', function () { label(btn); });
});
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Start the reze-test server.
#
# sh scripts/run.sh # listens on 0.0.0.0:3000
# PORT=8080 sh scripts/run.sh # listens on 0.0.0.0:8080
#
# POSIX sh only; no bashisms. Requires Node.js 22 or newer on PATH.
set -eu
cd "$(dirname "$0")/.."
PORT="${PORT:-3000}"
export PORT
echo "reze-test: starting on port ${PORT}" >&2
exec node server.js
+289
View File
@@ -0,0 +1,289 @@
import { createServer } from 'node:http';
import { readFileSync, statSync, realpathSync } from 'node:fs';
import { extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
estimateEntropy,
generateMemorizable,
generateRandom,
} from './src/generator.js';
import { listMeta } from './src/words.js';
const PUBLIC_DIR = resolve(fileURLToPath(new URL('./public/', import.meta.url)));
const NOT_FOUND_PAGE = resolve(PUBLIC_DIR, '404.html');
const MAX_BODY_BYTES = 64 * 1024;
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
const HOST = '0.0.0.0';
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'",
};
const CONTENT_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
function sendBuffer(req, res, status, body, headers) {
res.writeHead(status, {
...SECURITY_HEADERS,
'Content-Length': body.length,
...headers,
});
if (req.method === 'HEAD') {
res.end();
return;
}
res.end(body);
}
function sendJson(req, res, status, payload) {
const body = Buffer.from(JSON.stringify(payload));
sendBuffer(req, res, status, body, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
}
function sendError(req, res, status, message) {
sendJson(req, res, status, { error: message });
}
function sendNotFoundPage(req, res) {
let body;
try {
body = readFileSync(NOT_FOUND_PAGE);
} catch {
body = Buffer.from('<!doctype html><title>Not found</title><h1>Not found</h1>');
}
sendBuffer(req, res, 404, body, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
}
function contentTypeFor(filePath) {
return CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream';
}
function resolveStaticPath(pathname) {
let decoded;
try {
decoded = decodeURIComponent(pathname);
} catch {
return null;
}
if (decoded.includes('\0')) return null;
const relative = decoded === '/' ? 'index.html' : decoded.replace(/^\/+/, '');
const target = resolve(PUBLIC_DIR, relative);
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + sep)) {
return null;
}
return target;
}
function serveStatic(req, res, pathname) {
const target = resolveStaticPath(pathname);
if (!target) {
sendNotFoundPage(req, res);
return;
}
let stats;
let real;
try {
stats = statSync(target);
real = realpathSync(target);
} catch {
sendNotFoundPage(req, res);
return;
}
if (!stats.isFile() || (real !== PUBLIC_DIR && !real.startsWith(PUBLIC_DIR + sep))) {
sendNotFoundPage(req, res);
return;
}
let body;
try {
body = readFileSync(real);
} catch {
sendNotFoundPage(req, res);
return;
}
sendBuffer(req, res, 200, body, {
'Content-Type': contentTypeFor(real),
'Cache-Control': 'no-cache',
});
}
function readJsonBody(req) {
return new Promise((resolvePromise, rejectPromise) => {
const chunks = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
tooLarge = true;
chunks.length = 0;
if (size > MAX_BODY_BYTES * 8) {
req.destroy();
}
return;
}
chunks.push(chunk);
});
req.on('error', (err) => {
rejectPromise(Object.assign(err, { statusCode: 400 }));
});
req.on('end', () => {
if (tooLarge) {
rejectPromise(Object.assign(new Error('request body too large (limit 64 KB)'), { statusCode: 413 }));
return;
}
const raw = Buffer.concat(chunks).toString('utf8');
if (raw.trim() === '') {
resolvePromise({});
return;
}
try {
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
rejectPromise(Object.assign(new Error('request body must be a JSON object'), { statusCode: 400 }));
return;
}
resolvePromise(parsed);
} catch {
rejectPromise(Object.assign(new Error('malformed JSON body'), { statusCode: 400 }));
}
});
});
}
function countOption(body) {
const raw = body.count === undefined || body.count === null ? 1 : Number(body.count);
if (!Number.isInteger(raw) || raw < 1 || raw > 10) {
throw Object.assign(new Error('count must be an integer between 1 and 10'), { statusCode: 400 });
}
return raw;
}
async function handleGenerate(req, res, body) {
const mode = body.mode === undefined || body.mode === null ? 'memorable' : String(body.mode);
if (mode !== 'memorable' && mode !== 'random') {
sendError(req, res, 400, `unknown mode "${mode}"`);
return;
}
let count;
try {
count = countOption(body);
} catch (err) {
sendError(req, res, err.statusCode || 400, err.message);
return;
}
let entropy;
try {
entropy = estimateEntropy(mode, body);
} catch (err) {
sendError(req, res, 400, err.message);
return;
}
const passwords = [];
for (let i = 0; i < count; i += 1) {
let value;
try {
value = mode === 'memorable' ? generateMemorizable(body) : generateRandom(body);
} catch (err) {
sendError(req, res, 400, err.message);
return;
}
passwords.push({ value, mode, entropy });
}
sendJson(req, res, 200, { mode, count, passwords, entropy });
}
async function handleApi(req, res, pathname) {
if (pathname === '/api/meta') {
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendError(req, res, 405, 'method not allowed');
return;
}
sendJson(req, res, 200, { lists: listMeta() });
return;
}
if (pathname === '/api/generate') {
if (req.method !== 'POST') {
sendError(req, res, 405, 'method not allowed');
return;
}
let body;
try {
body = await readJsonBody(req);
} catch (err) {
sendError(req, res, err.statusCode || 400, err.message);
return;
}
await handleGenerate(req, res, body);
return;
}
sendError(req, res, 404, 'not found');
}
const server = createServer((req, res) => {
let pathname = '/';
try {
pathname = new URL(req.url || '/', 'http://localhost').pathname;
} catch {
sendError(req, res, 400, 'bad request');
return;
}
if (pathname === '/healthz') {
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendError(req, res, 405, 'method not allowed');
return;
}
sendJson(req, res, 200, { status: 'ok' });
return;
}
if (pathname.startsWith('/api/')) {
handleApi(req, res, pathname).catch((err) => {
sendError(req, res, 500, err && err.message ? err.message : 'internal error');
});
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
sendError(req, res, 405, 'method not allowed');
return;
}
serveStatic(req, res, pathname);
});
server.listen(PORT, HOST, () => {
console.log(`reze-test listening on http://${HOST}:${PORT}`);
});
+249
View File
@@ -0,0 +1,249 @@
import { randomInt } from 'node:crypto';
import { getWordList, listSize } from './words.js';
export const LOWER_CHARS = 'abcdefghijklmnopqrstuvwxyz';
export const UPPER_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const DIGIT_CHARS = '0123456789';
export const DEFAULT_SYMBOL_CHARS = '!@#$%^&*()-_=+[]{};:,.<>?';
export const AMBIGUOUS_CHARS = '0O1lI|';
export const GROUP_SEPARATORS = ['-', '_', '.', ' ', ''];
export const DIGIT_SEPARATORS = ['', '-'];
export const CAPITALIZATIONS = ['none', 'title', 'upper'];
const MEMORABLE_DEFAULTS = {
words: 4,
digits: 2,
digitSeparator: '',
separator: '-',
capitalization: 'none',
list: 'eff-large',
};
const RANDOM_DEFAULTS = {
length: 20,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: false,
customSymbols: '',
excludeAmbiguous: false,
requireEach: true,
};
function fail(message) {
throw new Error(message);
}
function intOption(value, fallback, min, max, name) {
const n = value === undefined || value === null || value === '' ? fallback : Number(value);
if (!Number.isInteger(n)) {
fail(`${name} must be an integer`);
}
if (n < min || n > max) {
fail(`${name} must be between ${min} and ${max}`);
}
return n;
}
function enumOption(value, fallback, allowed, name) {
const v = value === undefined || value === null ? fallback : String(value);
if (!allowed.includes(v)) {
fail(`${name} must be one of ${allowed.map((a) => JSON.stringify(a)).join(', ')}`);
}
return v;
}
function boolOption(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1' || value === 1) return true;
if (value === 'false' || value === '0' || value === 0) return false;
return Boolean(value);
}
function pick(items) {
return items[randomInt(items.length)];
}
function randomDigits(count) {
let out = '';
for (let i = 0; i < count; i += 1) {
out += String(randomInt(10));
}
return out;
}
function applyCapitalization(word, capitalization) {
if (capitalization === 'upper') return word.toUpperCase();
if (capitalization === 'title') {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
return word;
}
function normalizeMemorizable(opts) {
const words = intOption(opts.words, MEMORABLE_DEFAULTS.words, 2, 10, 'words');
const digits = intOption(opts.digits, MEMORABLE_DEFAULTS.digits, 0, 4, 'digits');
const digitSeparator = enumOption(
opts.digitSeparator,
MEMORABLE_DEFAULTS.digitSeparator,
DIGIT_SEPARATORS,
'digitSeparator',
);
const separator = enumOption(
opts.separator,
MEMORABLE_DEFAULTS.separator,
GROUP_SEPARATORS,
'separator',
);
const capitalization = enumOption(
opts.capitalization,
MEMORABLE_DEFAULTS.capitalization,
CAPITALIZATIONS,
'capitalization',
);
const list = opts.list === undefined || opts.list === null ? MEMORABLE_DEFAULTS.list : String(opts.list);
getWordList(list);
return { words, digits, digitSeparator, separator, capitalization, list };
}
function normalizeRandom(opts) {
const length = intOption(opts.length, RANDOM_DEFAULTS.length, 8, 64, 'length');
const useLower = boolOption(opts.useLower, RANDOM_DEFAULTS.useLower);
const useUpper = boolOption(opts.useUpper, RANDOM_DEFAULTS.useUpper);
const useDigits = boolOption(opts.useDigits, RANDOM_DEFAULTS.useDigits);
const useSymbols = boolOption(opts.useSymbols, RANDOM_DEFAULTS.useSymbols);
const customSymbols = typeof opts.customSymbols === 'string' ? opts.customSymbols : '';
const excludeAmbiguous = boolOption(opts.excludeAmbiguous, RANDOM_DEFAULTS.excludeAmbiguous);
const requireEach = boolOption(opts.requireEach, RANDOM_DEFAULTS.requireEach);
return {
length,
useLower,
useUpper,
useDigits,
useSymbols,
customSymbols,
excludeAmbiguous,
requireEach,
};
}
function buildPools(options) {
const classes = [];
if (options.useLower) classes.push({ name: 'lowercase', chars: LOWER_CHARS });
if (options.useUpper) classes.push({ name: 'uppercase', chars: UPPER_CHARS });
if (options.useDigits) classes.push({ name: 'digits', chars: DIGIT_CHARS });
if (options.useSymbols) {
const symbols = options.customSymbols.length > 0 ? options.customSymbols : DEFAULT_SYMBOL_CHARS;
classes.push({ name: 'symbols', chars: symbols });
}
if (options.excludeAmbiguous) {
for (const cls of classes) {
cls.chars = Array.from(cls.chars)
.filter((ch) => !AMBIGUOUS_CHARS.includes(ch))
.join('');
}
}
const seen = new Set();
let union = '';
for (const cls of classes) {
for (const ch of cls.chars) {
if (!seen.has(ch)) {
seen.add(ch);
union += ch;
}
}
}
return { classes, union };
}
function shuffle(chars) {
for (let i = chars.length - 1; i > 0; i -= 1) {
const j = randomInt(i + 1);
const tmp = chars[i];
chars[i] = chars[j];
chars[j] = tmp;
}
return chars;
}
export function generateMemorizable(opts = {}) {
const options = normalizeMemorizable(opts);
const words = getWordList(options.list);
const groups = [];
for (let i = 0; i < options.words; i += 1) {
const digits = randomDigits(options.digits);
const word = applyCapitalization(pick(words), options.capitalization);
const glue = options.digits > 0 ? options.digitSeparator : '';
groups.push(digits + glue + word);
}
return groups.join(options.separator);
}
export function generateRandom(opts = {}) {
const options = normalizeRandom(opts);
const { classes, union } = buildPools(options);
if (union.length === 0) {
fail('no characters available: enable at least one character set');
}
const chars = [];
if (options.requireEach) {
for (const cls of classes) {
if (chars.length >= options.length) break;
if (cls.chars.length === 0) continue;
chars.push(pick(cls.chars.split('')));
}
}
while (chars.length < options.length) {
chars.push(union[randomInt(union.length)]);
}
return shuffle(chars).join('');
}
export function entropyForMemorizable(opts = {}) {
const options = normalizeMemorizable(opts);
const size = listSize(options.list);
const bits = options.words * Math.log2(size) + options.words * options.digits * Math.log2(10);
return round1(bits);
}
export function entropyForRandom(opts = {}) {
const options = normalizeRandom(opts);
const { union } = buildPools(options);
if (union.length === 0) {
fail('no characters available: enable at least one character set');
}
const bits = options.length * Math.log2(union.length);
return round1(bits);
}
export function estimateEntropy(mode, opts = {}) {
if (mode === 'memorable') return entropyForMemorizable(opts);
if (mode === 'random') return entropyForRandom(opts);
return fail(`unknown mode "${mode}"`);
}
export function generate(mode, opts = {}) {
if (mode === 'memorable') return generateMemorizable(opts);
if (mode === 'random') return generateRandom(opts);
return fail(`unknown mode "${mode}"`);
}
function round1(n) {
return Math.round(n * 10) / 10;
}
+57
View File
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const WORDS_DIR = fileURLToPath(new URL('../public/words/', import.meta.url));
const LIST_DEFS = [
{ id: 'eff-large', name: 'EFF Large Word List', file: 'eff-large.txt' },
{ id: 'eff-short', name: 'EFF Short Word List (2.0)', file: 'eff-short.txt' },
];
const WORD_PATTERN = /^[a-z]+(?:-[a-z]+)*$/;
const lists = new Map();
function loadList(def) {
const raw = readFileSync(WORDS_DIR + def.file, 'utf8');
const words = raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
for (const word of words) {
if (!WORD_PATTERN.test(word)) {
throw new Error(`invalid word "${word}" in word list ${def.file}`);
}
}
if (words.length === 0) {
throw new Error(`word list ${def.id} is empty`);
}
return { id: def.id, name: def.name, words };
}
for (const def of LIST_DEFS) {
lists.set(def.id, loadList(def));
}
export function listIds() {
return Array.from(lists.keys());
}
export function getWordList(id) {
const list = lists.get(id);
if (!list) {
throw new Error(`unknown word list "${id}"`);
}
return list.words;
}
export function listSize(id) {
return getWordList(id).length;
}
export function listMeta() {
return LIST_DEFS.map((def) => ({
id: def.id,
name: def.name,
size: lists.get(def.id).words.length,
}));
}
+250
View File
@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_SYMBOL_CHARS,
entropyForMemorizable,
entropyForRandom,
generateMemorizable,
generateRandom,
} from '../src/generator.js';
import { getWordList, listIds, listSize } from '../src/words.js';
const LOWER = /[a-z]/;
const UPPER = /[A-Z]/;
const DIGIT = /[0-9]/;
const SYMBOL = /[^a-zA-Z0-9]/;
test('word lists are loaded and expose the expected sizes', () => {
assert.deepEqual(listIds().sort(), ['eff-large', 'eff-short']);
assert.equal(listSize('eff-large'), 7776);
assert.equal(listSize('eff-short'), 1296);
assert.equal(getWordList('eff-large').length, 7776);
assert.throws(() => getWordList('nope'), /unknown word list/);
});
test('random mode respects the requested length and only uses enabled classes', () => {
const value = generateRandom({
length: 24,
useLower: true,
useUpper: false,
useDigits: false,
useSymbols: false,
});
assert.equal(value.length, 24);
assert.match(value, /^[a-z]+$/);
});
test('random mode honours each enabled class', () => {
const value = generateRandom({
length: 32,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: true,
});
assert.equal(value.length, 32);
assert.match(value, /[a-z]/);
assert.match(value, /[A-Z]/);
assert.match(value, /[0-9]/);
assert.match(value, SYMBOL);
});
test('random mode with requireEach contains at least one char from each enabled class', () => {
for (let attempt = 0; attempt < 25; attempt += 1) {
const value = generateRandom({
length: 16,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: true,
requireEach: true,
});
assert.equal(value.length, 16);
assert.match(value, LOWER);
assert.match(value, UPPER);
assert.match(value, DIGIT);
assert.match(value, SYMBOL);
}
});
test('random mode uses the custom symbol set instead of the default one', () => {
const value = generateRandom({
length: 20,
useLower: true,
useUpper: false,
useDigits: false,
useSymbols: true,
customSymbols: '@#',
requireEach: true,
});
assert.match(value, /^[a-z@#]+$/);
assert.match(value, /[@#]/);
assert.ok(!value.includes('!'));
assert.equal(DEFAULT_SYMBOL_CHARS.includes('!'), true);
});
test('random mode excludes ambiguous characters on request', () => {
const value = generateRandom({
length: 60,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: true,
excludeAmbiguous: true,
});
assert.equal(value.length, 60);
for (const ch of ['0', 'O', '1', 'l', 'I', '|']) {
assert.ok(!value.includes(ch), `expected ${ch} to be excluded`);
}
});
test('random mode throws when every set is disabled', () => {
assert.throws(
() => generateRandom({
length: 16,
useLower: false,
useUpper: false,
useDigits: false,
useSymbols: false,
}),
/no characters available/,
);
});
test('memorizable output has the requested groups, digits and words', () => {
const words = getWordList('eff-large');
const value = generateMemorizable({
words: 3,
digits: 2,
digitSeparator: '',
separator: '_',
capitalization: 'none',
list: 'eff-large',
});
assert.match(value, /^[0-9a-zA-Z]*([\-_. ]?[0-9a-zA-Z]+)*$/);
const groups = value.split('_');
assert.equal(groups.length, 3);
for (const group of groups) {
assert.match(group, /^\d{2}[a-z]+(-[a-z]+)*$/);
const word = group.slice(2);
assert.ok(words.includes(word), `expected "${word}" to come from the word list`);
}
});
test('memorizable output supports every group separator', () => {
for (const separator of ['_', '.', ' ']) {
const value = generateMemorizable({
words: 2,
digits: 0,
separator,
capitalization: 'none',
list: 'eff-short',
});
assert.equal(value.split(separator).length, 2);
}
});
test('memorizable output applies the digit separator', () => {
const value = generateMemorizable({
words: 2,
digits: 3,
digitSeparator: '-',
separator: '_',
capitalization: 'none',
list: 'eff-short',
});
const groups = value.split('_');
assert.equal(groups.length, 2);
for (const group of groups) {
assert.match(group, /^\d{3}-[a-z]+(-[a-z]+)*$/);
}
});
test('memorizable output applies capitalization', () => {
const none = generateMemorizable({ words: 2, digits: 0, capitalization: 'none', list: 'eff-short' }).split('-')[0];
const title = generateMemorizable({ words: 2, digits: 0, capitalization: 'title', list: 'eff-short' }).split('-')[0];
const upper = generateMemorizable({ words: 2, digits: 0, capitalization: 'upper', list: 'eff-short' }).split('-')[0];
assert.match(none, /^[a-z]+(-[a-z]+)*$/);
assert.match(title, /^[A-Z][a-z]*(-[a-z]+)*$/);
assert.match(upper, /^[A-Z]+(-[A-Z]+)*$/);
});
test('memorizable with digits=0 contains no digits', () => {
for (let attempt = 0; attempt < 10; attempt += 1) {
const value = generateMemorizable({
words: 5,
digits: 0,
separator: '-',
capitalization: 'none',
list: 'eff-large',
});
assert.ok(!/[0-9]/.test(value), 'expected no digits');
assert.equal(value.split('-').length, 5);
}
});
test('memorizable rejects unknown word lists and out-of-range options', () => {
assert.throws(
() => generateMemorizable({ words: 2, digits: 2, list: 'nope' }),
/unknown word list/,
);
assert.throws(
() => generateMemorizable({ words: 99, digits: 2 }),
/words must be between 2 and 10/,
);
assert.throws(
() => generateMemorizable({ words: 2, digits: 9 }),
/digits must be between 0 and 4/,
);
});
test('two consecutive calls differ', () => {
const a = generateRandom({ length: 24, useLower: true, useUpper: true, useDigits: true, useSymbols: true });
const b = generateRandom({ length: 24, useLower: true, useUpper: true, useDigits: true, useSymbols: true });
assert.notEqual(a, b);
const m1 = generateMemorizable({ words: 4, digits: 2, separator: '-', capitalization: 'title' });
const m2 = generateMemorizable({ words: 4, digits: 2, separator: '-', capitalization: 'title' });
assert.notEqual(m1, m2);
});
test('entropy helpers return sane numbers', () => {
const short = entropyForRandom({ length: 8, useLower: true });
const long = entropyForRandom({ length: 64, useLower: true });
assert.ok(short > 0, 'expected positive entropy');
assert.ok(long > short, 'expected entropy to grow with length');
assert.equal(short, Math.round(short * 10) / 10);
assert.equal(long, Math.round(long * 10) / 10);
const symbols = entropyForRandom({
length: 8,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: true,
});
assert.ok(symbols > short);
const memorable = entropyForMemorizable({ words: 4, digits: 2, list: 'eff-large' });
const expected = 4 * Math.log2(7776) + 4 * 2 * Math.log2(10);
assert.equal(memorable, Math.round(expected * 10) / 10);
assert.ok(memorable > 0);
const moreWords = entropyForMemorizable({ words: 5, digits: 2, list: 'eff-large' });
assert.ok(moreWords > memorable);
const shorter = entropyForMemorizable({ words: 4, digits: 0, list: 'eff-large' });
assert.ok(shorter < memorable);
});