Six fixes, each with a test: 1. Character pools are built as arrays of code points, so astral characters (emoji) are never split into lone surrogates. Entropy counts code points. 2. Boolean options are parsed strictly: true/false, 1/0, "true"/"false", "1"/"0", "yes"/"no", "on"/"off" (case-insensitive). Anything else is a 400 naming the option instead of a silent truthy fallback. 3. customSymbols is validated: at most 64 code points, duplicates dropped, control characters and whitespace rejected with a 400. 4. Random mode entropy with requireEach now counts only the passwords that contain a character of every enabled class (inclusion-exclusion). The value is an upper bound and is labelled as such in the API (entropyApproximate/entropyNote) and in the UI. 5. docker-compose.yml uses expose instead of ports, so it no longer collides with Dokploy's own port 3000. 6. SIGTERM/SIGINT shut the server down gracefully: close the listener, let in-flight requests finish, force-exit after 5s. Measured at ~60ms. The server now exports createServer() so the API can be tested over HTTP without binding a fixed port.
214 lines
7.8 KiB
Markdown
214 lines
7.8 KiB
Markdown
# 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.
|
||
- Unicode-safe: character pools are code points, so emoji are never split.
|
||
- Strict option validation — bad booleans and symbol sets are rejected, not guessed.
|
||
- 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,"entropyApproximate":false},{"value":"69reassign-85probation","mode":"memorable","entropy":39.1,"entropyApproximate":false}],"entropy":39.1,"entropyApproximate":false,"entropyNote":"exact: every character is drawn uniformly from the enabled sets"}
|
||
```
|
||
|
||
Random options: `length` (8–64, in code points), `useLower`, `useUpper`, `useDigits`,
|
||
`useSymbols`, `customSymbols` (replaces the default symbol set when non-empty),
|
||
`excludeAmbiguous` (removes `0 O 1 l I |`) and `requireEach`.
|
||
|
||
The boolean options are parsed strictly: `true`/`false`, `1`/`0`, `"true"`/`"false"`,
|
||
`"1"`/`"0"`, `"yes"`/`"no"` and `"on"`/`"off"` (case-insensitive) are accepted, anything
|
||
else is rejected with a `400` that names the option. `"no"` really means *off*, it is not
|
||
treated as a truthy string.
|
||
|
||
`customSymbols` is validated: at most 64 code points, duplicates are dropped, and
|
||
control characters or whitespace are rejected with a `400`. Characters are handled as
|
||
code points, so emoji and other astral characters are never split in half.
|
||
|
||
```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,"entropyApproximate":true}],"entropy":154.6,"entropyApproximate":true,"entropyNote":"upper bound: the generator samples the valid passwords non-uniformly"}
|
||
```
|
||
|
||
### Entropy
|
||
|
||
- Random mode without `requireEach`: `length * log2(poolSize)`, where `poolSize` is the
|
||
number of code points in the union of all enabled character classes.
|
||
- Random mode with `requireEach`: the number of passwords that contain at least one
|
||
character of every enabled class (inclusion-exclusion over the classes), converted to
|
||
bits. Because the generator samples those passwords non-uniformly, the value is an
|
||
**upper bound**; the response says so via `entropyApproximate` and `entropyNote`, and
|
||
the UI marks it with a `~` and "approx.".
|
||
- Memorable mode: `words * log2(listSize) + words * digits * log2(10)`. Separators and the
|
||
capitalization scheme add no entropy. This value is exact.
|
||
|
||
Every response carries `entropy`, `entropyApproximate` and `entropyNote`, and each
|
||
password carries its own `entropy` and `entropyApproximate`.
|
||
|
||
### 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`.
|
||
|
||
`docker-compose.yml` uses `expose` instead of `ports`, so the service is only reachable
|
||
on the container network — publishing it on the host would collide with Dokploy's own
|
||
port 3000.
|
||
|
||
`SIGTERM` and `SIGINT` shut the server down gracefully: it stops accepting connections,
|
||
lets the in-flight requests finish and exits, with a 5 second forced-exit fallback. That
|
||
keeps `docker stop` fast instead of waiting for the full 10 second timeout.
|
||
|
||
## 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`.
|
||
- Deploys are triggered from the Dokploy UI (or a Gitea webhook configured there).
|
||
CI does not deploy automatically: `.woodpecker.yml` is a manual-only template. If you
|
||
want a push-to-deploy hook, set a `DEPLOY_URL` repository variable (the Dokploy deploy
|
||
webhook URL) and switch the pipeline trigger back to `event: push` on `trunk`.
|
||
|
||
## License
|
||
|
||
MIT for the code. The bundled word lists are CC BY 3.0, © Electronic Frontier Foundation.
|