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