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:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
npm-debug.log
|
||||
test
|
||||
*.md
|
||||
.woodpecker.yml
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: reze-test:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PORT: 3000
|
||||
restart: unless-stopped
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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 — 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="!@#$%^&*()-_=+[]{};:,.<>?" 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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user