reze: Fix unicode splitting, strict booleans, symbol validation and entropy
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.
This commit is contained in:
@@ -19,6 +19,8 @@ account, no login, no database, and nothing you generate is stored or logged.
|
|||||||
- One-click copy with a clipboard fallback.
|
- One-click copy with a clipboard fallback.
|
||||||
- Light/dark theme with a system-theme fallback, persisted in `localStorage`.
|
- Light/dark theme with a system-theme fallback, persisted in `localStorage`.
|
||||||
- Options persisted in `localStorage`; generated passwords are never persisted.
|
- 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.
|
- No npm dependencies at all — Node.js built-ins only.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -100,12 +102,21 @@ curl -s -X POST localhost:3000/api/generate \
|
|||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```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}
|
{"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), `useLower`, `useUpper`, `useDigits`, `useSymbols`,
|
Random options: `length` (8–64, in code points), `useLower`, `useUpper`, `useDigits`,
|
||||||
`customSymbols` (replaces the default symbol set when non-empty), `excludeAmbiguous`
|
`useSymbols`, `customSymbols` (replaces the default symbol set when non-empty),
|
||||||
(removes `0 O 1 l I |`) and `requireEach`.
|
`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
|
```sh
|
||||||
curl -s -X POST localhost:3000/api/generate \
|
curl -s -X POST localhost:3000/api/generate \
|
||||||
@@ -114,15 +125,23 @@ curl -s -X POST localhost:3000/api/generate \
|
|||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"mode":"random","count":1,"passwords":[{"value":"3V#P@bQ2C4c3;=igw}E(EpnG","mode":"random","entropy":154.6}],"entropy":154.6}
|
{"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
|
### Entropy
|
||||||
|
|
||||||
- Random mode: `length * log2(poolSize)`, where `poolSize` is the size of the union of all
|
- Random mode without `requireEach`: `length * log2(poolSize)`, where `poolSize` is the
|
||||||
enabled character classes.
|
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
|
- Memorable mode: `words * log2(listSize) + words * digits * log2(10)`. Separators and the
|
||||||
capitalization scheme add no entropy.
|
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
|
### Other paths
|
||||||
|
|
||||||
@@ -169,6 +188,14 @@ docker compose up --build -d
|
|||||||
The image is a single stage based on `node:22-alpine`, runs as the non-root `node` user
|
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`.
|
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)
|
## Deployment (Dokploy)
|
||||||
|
|
||||||
- Repository: `druid520/reze-test` on Gitea (git.kg.boson.party).
|
- Repository: `druid520/reze-test` on Gitea (git.kg.boson.party).
|
||||||
|
|||||||
+4
-2
@@ -2,8 +2,10 @@ services:
|
|||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
image: reze-test:latest
|
image: reze-test:latest
|
||||||
ports:
|
# Only exposed to the container network: publishing the port on the host
|
||||||
- "3000:3000"
|
# would collide with Dokploy's own port 3000.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+11
-4
@@ -239,12 +239,16 @@
|
|||||||
|
|
||||||
const entropy = document.createElement('span');
|
const entropy = document.createElement('span');
|
||||||
entropy.className = 'pw-entropy';
|
entropy.className = 'pw-entropy';
|
||||||
entropy.textContent = item.entropy + ' bits';
|
var approximate = Boolean(item.entropyApproximate);
|
||||||
|
entropy.textContent = (approximate ? '~' : '') + item.entropy + ' bits';
|
||||||
|
if (approximate) {
|
||||||
|
entropy.setAttribute('title', 'Upper bound: the generator samples the valid passwords non-uniformly.');
|
||||||
|
}
|
||||||
|
|
||||||
const level = strengthLevel(item.entropy);
|
const level = strengthLevel(item.entropy);
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'pw-strength-label';
|
label.className = 'pw-strength-label';
|
||||||
label.textContent = STRENGTH_LABELS[level];
|
label.textContent = STRENGTH_LABELS[level] + (approximate ? ' (approx.)' : '');
|
||||||
|
|
||||||
const copy = document.createElement('button');
|
const copy = document.createElement('button');
|
||||||
copy.type = 'button';
|
copy.type = 'button';
|
||||||
@@ -269,18 +273,21 @@
|
|||||||
|
|
||||||
function payloadFor(state) {
|
function payloadFor(state) {
|
||||||
if (state.mode === 'random') {
|
if (state.mode === 'random') {
|
||||||
return {
|
var payload = {
|
||||||
mode: 'random',
|
mode: 'random',
|
||||||
length: state.length,
|
length: state.length,
|
||||||
useLower: state.useLower,
|
useLower: state.useLower,
|
||||||
useUpper: state.useUpper,
|
useUpper: state.useUpper,
|
||||||
useDigits: state.useDigits,
|
useDigits: state.useDigits,
|
||||||
useSymbols: state.useSymbols,
|
useSymbols: state.useSymbols,
|
||||||
customSymbols: state.customSymbols,
|
|
||||||
excludeAmbiguous: state.excludeAmbiguous,
|
excludeAmbiguous: state.excludeAmbiguous,
|
||||||
requireEach: state.requireEach,
|
requireEach: state.requireEach,
|
||||||
count: state.count
|
count: state.count
|
||||||
};
|
};
|
||||||
|
// The server rejects invalid symbol sets, so only send the custom set
|
||||||
|
// when the symbols class is actually in use.
|
||||||
|
if (state.useSymbols) payload.customSymbols = state.customSymbols;
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mode: 'memorable',
|
mode: 'memorable',
|
||||||
|
|||||||
@@ -98,6 +98,10 @@
|
|||||||
|
|
||||||
<h2>Output</h2>
|
<h2>Output</h2>
|
||||||
|
|
||||||
|
<p class="hint">Entropy is exact for memorable passphrases. In random mode it is an upper
|
||||||
|
bound when “require at least one of each selected set” is on, because the
|
||||||
|
generator samples the valid passwords non-uniformly.</p>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="count">How many passwords</label>
|
<label for="count">How many passwords</label>
|
||||||
<input type="number" id="count" min="1" max="10" step="1" value="1">
|
<input type="number" id="count" min="1" max="10" step="1" value="1">
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ main.error p { color: var(--muted); margin: 0 0 2rem; }
|
|||||||
|
|
||||||
.lead { margin: -1.5rem 0 0; color: var(--muted); }
|
.lead { margin: -1.5rem 0 0; color: var(--muted); }
|
||||||
|
|
||||||
|
.hint { margin: -.25rem 0 1.25rem; color: var(--muted); font-size: .85rem; }
|
||||||
|
|
||||||
.radios { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; }
|
.radios { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; }
|
||||||
|
|
||||||
.radio,
|
.radio,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer as createHttpServer } from 'node:http';
|
||||||
import { readFileSync, statSync, realpathSync } from 'node:fs';
|
import { readFileSync, statSync, realpathSync } from 'node:fs';
|
||||||
import { extname, resolve, sep } from 'node:path';
|
import { extname, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
entropyIsApproximate,
|
||||||
estimateEntropy,
|
estimateEntropy,
|
||||||
generateMemorizable,
|
generateMemorizable,
|
||||||
generateRandom,
|
generateRandom,
|
||||||
@@ -15,6 +16,7 @@ const NOT_FOUND_PAGE = resolve(PUBLIC_DIR, '404.html');
|
|||||||
const MAX_BODY_BYTES = 64 * 1024;
|
const MAX_BODY_BYTES = 64 * 1024;
|
||||||
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
||||||
const HOST = '0.0.0.0';
|
const HOST = '0.0.0.0';
|
||||||
|
const SHUTDOWN_GRACE_MS = 5000;
|
||||||
|
|
||||||
const SECURITY_HEADERS = {
|
const SECURITY_HEADERS = {
|
||||||
'X-Content-Type-Options': 'nosniff',
|
'X-Content-Type-Options': 'nosniff',
|
||||||
@@ -200,8 +202,10 @@ async function handleGenerate(req, res, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let entropy;
|
let entropy;
|
||||||
|
let approximate;
|
||||||
try {
|
try {
|
||||||
entropy = estimateEntropy(mode, body);
|
entropy = estimateEntropy(mode, body);
|
||||||
|
approximate = entropyIsApproximate(mode, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(req, res, 400, err.message);
|
sendError(req, res, 400, err.message);
|
||||||
return;
|
return;
|
||||||
@@ -216,10 +220,19 @@ async function handleGenerate(req, res, body) {
|
|||||||
sendError(req, res, 400, err.message);
|
sendError(req, res, 400, err.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
passwords.push({ value, mode, entropy });
|
passwords.push({ value, mode, entropy, entropyApproximate: approximate });
|
||||||
}
|
}
|
||||||
|
|
||||||
sendJson(req, res, 200, { mode, count, passwords, entropy });
|
sendJson(req, res, 200, {
|
||||||
|
mode,
|
||||||
|
count,
|
||||||
|
passwords,
|
||||||
|
entropy,
|
||||||
|
entropyApproximate: approximate,
|
||||||
|
entropyNote: approximate
|
||||||
|
? 'upper bound: the generator samples the valid passwords non-uniformly'
|
||||||
|
: 'exact: every character is drawn uniformly from the enabled sets',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApi(req, res, pathname) {
|
async function handleApi(req, res, pathname) {
|
||||||
@@ -251,7 +264,8 @@ async function handleApi(req, res, pathname) {
|
|||||||
sendError(req, res, 404, 'not found');
|
sendError(req, res, 404, 'not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
function createRequestHandler() {
|
||||||
|
return (req, res) => {
|
||||||
let pathname = '/';
|
let pathname = '/';
|
||||||
try {
|
try {
|
||||||
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
@@ -282,8 +296,49 @@ const server = createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serveStatic(req, res, pathname);
|
serveStatic(req, res, pathname);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the HTTP server without binding it, so it can be used in tests. */
|
||||||
|
export function createServer() {
|
||||||
|
return createHttpServer(createRequestHandler());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the server and installs graceful shutdown: SIGTERM/SIGINT stop
|
||||||
|
* accepting connections, let the in-flight ones finish, and force an exit
|
||||||
|
* after a 5 second grace period so container stops are quick.
|
||||||
|
*/
|
||||||
|
export function startServer({ port = PORT, host = HOST } = {}) {
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
const shutdown = (signal) => {
|
||||||
|
console.log(`reze-test: ${signal} received, shutting down`);
|
||||||
|
server.close(() => {
|
||||||
|
console.log('reze-test: connections closed, exiting');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
if (typeof server.closeIdleConnections === 'function') {
|
||||||
|
server.closeIdleConnections();
|
||||||
|
}
|
||||||
|
const forced = setTimeout(() => {
|
||||||
|
console.error('reze-test: shutdown timed out, forcing exit');
|
||||||
|
process.exit(1);
|
||||||
|
}, SHUTDOWN_GRACE_MS);
|
||||||
|
forced.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`reze-test listening on http://${host}:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
return server;
|
||||||
console.log(`reze-test listening on http://${HOST}:${PORT}`);
|
}
|
||||||
});
|
|
||||||
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||||
|
if (isMain) {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|||||||
+137
-26
@@ -12,6 +12,12 @@ export const GROUP_SEPARATORS = ['-', '_', '.', ' ', ''];
|
|||||||
export const DIGIT_SEPARATORS = ['', '-'];
|
export const DIGIT_SEPARATORS = ['', '-'];
|
||||||
export const CAPITALIZATIONS = ['none', 'title', 'upper'];
|
export const CAPITALIZATIONS = ['none', 'title', 'upper'];
|
||||||
|
|
||||||
|
/** Maximum number of code points allowed in a custom symbol set. */
|
||||||
|
export const MAX_CUSTOM_SYMBOLS = 64;
|
||||||
|
|
||||||
|
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
|
||||||
|
const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
|
||||||
|
|
||||||
const MEMORABLE_DEFAULTS = {
|
const MEMORABLE_DEFAULTS = {
|
||||||
words: 4,
|
words: 4,
|
||||||
digits: 2,
|
digits: 2,
|
||||||
@@ -55,12 +61,23 @@ function enumOption(value, fallback, allowed, name) {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boolOption(value, fallback) {
|
/**
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
* Strict boolean parsing. Accepts real booleans, 1/0 and the strings
|
||||||
|
* "true"/"false", "1"/"0", "yes"/"no" and "on"/"off" (case-insensitive).
|
||||||
|
* Anything else is an error instead of a silent truthy fallback.
|
||||||
|
*/
|
||||||
|
function boolOption(value, fallback, name) {
|
||||||
|
if (value === undefined || value === null) return fallback;
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === 'true' || value === '1' || value === 1) return true;
|
if (typeof value === 'number') {
|
||||||
if (value === 'false' || value === '0' || value === 0) return false;
|
if (value === 1) return true;
|
||||||
return Boolean(value);
|
if (value === 0) return false;
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (TRUE_VALUES.has(normalized)) return true;
|
||||||
|
if (FALSE_VALUES.has(normalized)) return false;
|
||||||
|
}
|
||||||
|
fail(`${name} must be a boolean (true/false, 1/0, yes/no, on/off)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(items) {
|
function pick(items) {
|
||||||
@@ -83,6 +100,39 @@ function applyCapitalization(word, capitalization) {
|
|||||||
return word;
|
return word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a custom symbol set: splits it into code points (so astral
|
||||||
|
* characters survive intact), drops duplicates and rejects control
|
||||||
|
* characters and whitespace.
|
||||||
|
*/
|
||||||
|
function normalizeCustomSymbols(value) {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
fail('customSymbols must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chars = Array.from(value);
|
||||||
|
if (chars.length > MAX_CUSTOM_SYMBOLS) {
|
||||||
|
fail(`customSymbols must contain at most ${MAX_CUSTOM_SYMBOLS} characters`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const ch of chars) {
|
||||||
|
if (seen.has(ch)) continue;
|
||||||
|
seen.add(ch);
|
||||||
|
unique.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ch of unique) {
|
||||||
|
if (/\p{Cc}/u.test(ch) || /\p{Cf}/u.test(ch) || /\s/u.test(ch)) {
|
||||||
|
fail('customSymbols must not contain control characters or whitespace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemorizable(opts) {
|
function normalizeMemorizable(opts) {
|
||||||
const words = intOption(opts.words, MEMORABLE_DEFAULTS.words, 2, 10, 'words');
|
const words = intOption(opts.words, MEMORABLE_DEFAULTS.words, 2, 10, 'words');
|
||||||
const digits = intOption(opts.digits, MEMORABLE_DEFAULTS.digits, 0, 4, 'digits');
|
const digits = intOption(opts.digits, MEMORABLE_DEFAULTS.digits, 0, 4, 'digits');
|
||||||
@@ -112,13 +162,17 @@ function normalizeMemorizable(opts) {
|
|||||||
|
|
||||||
function normalizeRandom(opts) {
|
function normalizeRandom(opts) {
|
||||||
const length = intOption(opts.length, RANDOM_DEFAULTS.length, 8, 64, 'length');
|
const length = intOption(opts.length, RANDOM_DEFAULTS.length, 8, 64, 'length');
|
||||||
const useLower = boolOption(opts.useLower, RANDOM_DEFAULTS.useLower);
|
const useLower = boolOption(opts.useLower, RANDOM_DEFAULTS.useLower, 'useLower');
|
||||||
const useUpper = boolOption(opts.useUpper, RANDOM_DEFAULTS.useUpper);
|
const useUpper = boolOption(opts.useUpper, RANDOM_DEFAULTS.useUpper, 'useUpper');
|
||||||
const useDigits = boolOption(opts.useDigits, RANDOM_DEFAULTS.useDigits);
|
const useDigits = boolOption(opts.useDigits, RANDOM_DEFAULTS.useDigits, 'useDigits');
|
||||||
const useSymbols = boolOption(opts.useSymbols, RANDOM_DEFAULTS.useSymbols);
|
const useSymbols = boolOption(opts.useSymbols, RANDOM_DEFAULTS.useSymbols, 'useSymbols');
|
||||||
const customSymbols = typeof opts.customSymbols === 'string' ? opts.customSymbols : '';
|
const customSymbols = normalizeCustomSymbols(opts.customSymbols);
|
||||||
const excludeAmbiguous = boolOption(opts.excludeAmbiguous, RANDOM_DEFAULTS.excludeAmbiguous);
|
const excludeAmbiguous = boolOption(
|
||||||
const requireEach = boolOption(opts.requireEach, RANDOM_DEFAULTS.requireEach);
|
opts.excludeAmbiguous,
|
||||||
|
RANDOM_DEFAULTS.excludeAmbiguous,
|
||||||
|
'excludeAmbiguous',
|
||||||
|
);
|
||||||
|
const requireEach = boolOption(opts.requireEach, RANDOM_DEFAULTS.requireEach, 'requireEach');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
length,
|
length,
|
||||||
@@ -132,32 +186,34 @@ function normalizeRandom(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the character pools as arrays of code points, so multi-unit
|
||||||
|
* characters (emoji, combining marks) are never split.
|
||||||
|
*/
|
||||||
function buildPools(options) {
|
function buildPools(options) {
|
||||||
const classes = [];
|
const classes = [];
|
||||||
|
|
||||||
if (options.useLower) classes.push({ name: 'lowercase', chars: LOWER_CHARS });
|
if (options.useLower) classes.push({ name: 'lowercase', chars: Array.from(LOWER_CHARS) });
|
||||||
if (options.useUpper) classes.push({ name: 'uppercase', chars: UPPER_CHARS });
|
if (options.useUpper) classes.push({ name: 'uppercase', chars: Array.from(UPPER_CHARS) });
|
||||||
if (options.useDigits) classes.push({ name: 'digits', chars: DIGIT_CHARS });
|
if (options.useDigits) classes.push({ name: 'digits', chars: Array.from(DIGIT_CHARS) });
|
||||||
if (options.useSymbols) {
|
if (options.useSymbols) {
|
||||||
const symbols = options.customSymbols.length > 0 ? options.customSymbols : DEFAULT_SYMBOL_CHARS;
|
const symbols = options.customSymbols.length > 0 ? options.customSymbols : DEFAULT_SYMBOL_CHARS;
|
||||||
classes.push({ name: 'symbols', chars: symbols });
|
classes.push({ name: 'symbols', chars: Array.from(symbols) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.excludeAmbiguous) {
|
if (options.excludeAmbiguous) {
|
||||||
for (const cls of classes) {
|
for (const cls of classes) {
|
||||||
cls.chars = Array.from(cls.chars)
|
cls.chars = cls.chars.filter((ch) => !AMBIGUOUS_CHARS.includes(ch));
|
||||||
.filter((ch) => !AMBIGUOUS_CHARS.includes(ch))
|
|
||||||
.join('');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const union = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
let union = '';
|
|
||||||
for (const cls of classes) {
|
for (const cls of classes) {
|
||||||
for (const ch of cls.chars) {
|
for (const ch of cls.chars) {
|
||||||
if (!seen.has(ch)) {
|
if (!seen.has(ch)) {
|
||||||
seen.add(ch);
|
seen.add(ch);
|
||||||
union += ch;
|
union.push(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +231,31 @@ function shuffle(chars) {
|
|||||||
return chars;
|
return chars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of distinct strings of the given length over the pool that contain
|
||||||
|
* at least one character from every enabled class, via inclusion-exclusion.
|
||||||
|
*/
|
||||||
|
function countStringsWithAllClasses(length, classes, unionSize) {
|
||||||
|
const n = classes.length;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (let mask = 0; mask < 1 << n; mask += 1) {
|
||||||
|
const excluded = new Set();
|
||||||
|
let bits = 0;
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
if (mask & (1 << i)) {
|
||||||
|
bits += 1;
|
||||||
|
for (const ch of classes[i].chars) excluded.add(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remaining = unionSize - excluded.size;
|
||||||
|
const term = Math.pow(remaining, length);
|
||||||
|
count += bits % 2 === 0 ? term : -term;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
export function generateMemorizable(opts = {}) {
|
export function generateMemorizable(opts = {}) {
|
||||||
const options = normalizeMemorizable(opts);
|
const options = normalizeMemorizable(opts);
|
||||||
const words = getWordList(options.list);
|
const words = getWordList(options.list);
|
||||||
@@ -204,12 +285,12 @@ export function generateRandom(opts = {}) {
|
|||||||
for (const cls of classes) {
|
for (const cls of classes) {
|
||||||
if (chars.length >= options.length) break;
|
if (chars.length >= options.length) break;
|
||||||
if (cls.chars.length === 0) continue;
|
if (cls.chars.length === 0) continue;
|
||||||
chars.push(pick(cls.chars.split('')));
|
chars.push(pick(cls.chars));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (chars.length < options.length) {
|
while (chars.length < options.length) {
|
||||||
chars.push(union[randomInt(union.length)]);
|
chars.push(pick(union));
|
||||||
}
|
}
|
||||||
|
|
||||||
return shuffle(chars).join('');
|
return shuffle(chars).join('');
|
||||||
@@ -224,12 +305,42 @@ export function entropyForMemorizable(opts = {}) {
|
|||||||
|
|
||||||
export function entropyForRandom(opts = {}) {
|
export function entropyForRandom(opts = {}) {
|
||||||
const options = normalizeRandom(opts);
|
const options = normalizeRandom(opts);
|
||||||
const { union } = buildPools(options);
|
const { classes, union } = buildPools(options);
|
||||||
if (union.length === 0) {
|
if (union.length === 0) {
|
||||||
fail('no characters available: enable at least one character set');
|
fail('no characters available: enable at least one character set');
|
||||||
}
|
}
|
||||||
const bits = options.length * Math.log2(union.length);
|
|
||||||
return round1(bits);
|
if (!options.requireEach) {
|
||||||
|
return round1(options.length * Math.log2(union.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
const usable = classes.filter((cls) => cls.chars.length > 0);
|
||||||
|
if (usable.length > 1 && options.length >= usable.length) {
|
||||||
|
const count = countStringsWithAllClasses(options.length, classes, union.length);
|
||||||
|
if (count > 0) {
|
||||||
|
return round1(Math.log2(count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return round1(options.length * Math.log2(union.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the reported entropy is an upper bound rather than the exact
|
||||||
|
* information content of the generator. That is the case as soon as
|
||||||
|
* "require one of each class" is on for more than one class: the generator
|
||||||
|
* samples the valid strings non-uniformly.
|
||||||
|
*/
|
||||||
|
export function entropyIsApproximate(mode, opts = {}) {
|
||||||
|
if (mode === 'memorable') return false;
|
||||||
|
if (mode !== 'random') return fail(`unknown mode "${mode}"`);
|
||||||
|
|
||||||
|
const options = normalizeRandom(opts);
|
||||||
|
if (!options.requireEach) return false;
|
||||||
|
|
||||||
|
const { classes } = buildPools(options);
|
||||||
|
const usable = classes.filter((cls) => cls.chars.length > 0);
|
||||||
|
return usable.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateEntropy(mode, opts = {}) {
|
export function estimateEntropy(mode, opts = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { createServer } from '../server.js';
|
||||||
|
|
||||||
|
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
|
async function withServer(fn) {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
try {
|
||||||
|
await fn(base);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(base, body) {
|
||||||
|
return fetch(`${base}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the API rejects unparseable booleans with a 400 naming the option', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const response = await post(base, {
|
||||||
|
mode: 'random',
|
||||||
|
length: 16,
|
||||||
|
useLower: 'maybe',
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.match(payload.error, /useLower/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the API accepts "no" as false', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const response = await post(base, {
|
||||||
|
mode: 'random',
|
||||||
|
length: 16,
|
||||||
|
useLower: 'no',
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(payload.passwords.length, 1);
|
||||||
|
assert.ok(!/[a-z]/.test(payload.passwords[0].value), 'expected no lowercase characters');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the API rejects invalid custom symbol sets with a 400', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const cases = [
|
||||||
|
['a b', /whitespace/],
|
||||||
|
['a\tb', /control characters or whitespace/],
|
||||||
|
['x'.repeat(65), /at most 64/],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [customSymbols, pattern] of cases) {
|
||||||
|
const response = await post(base, {
|
||||||
|
mode: 'random',
|
||||||
|
length: 16,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 400, `expected 400 for ${JSON.stringify(customSymbols)}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.match(payload.error, pattern);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the API never splits astral characters', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const response = await post(base, {
|
||||||
|
mode: 'random',
|
||||||
|
length: 12,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: '😀',
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const payload = await response.json();
|
||||||
|
const value = payload.passwords[0].value;
|
||||||
|
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value));
|
||||||
|
assert.equal(Array.from(value).length, 12);
|
||||||
|
for (const codePoint of Array.from(value)) {
|
||||||
|
assert.equal(codePoint, '😀');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the API labels an upper bound entropy as approximate', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const constrained = await post(base, {
|
||||||
|
mode: 'random',
|
||||||
|
length: 16,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: true,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: true,
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
const constrainedPayload = await constrained.json();
|
||||||
|
assert.equal(constrainedPayload.entropyApproximate, true);
|
||||||
|
assert.equal(constrainedPayload.passwords[0].entropyApproximate, true);
|
||||||
|
assert.match(constrainedPayload.entropyNote, /upper bound/);
|
||||||
|
|
||||||
|
const plain = await post(base, {
|
||||||
|
mode: 'random',
|
||||||
|
length: 16,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: true,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: true,
|
||||||
|
requireEach: false,
|
||||||
|
});
|
||||||
|
const plainPayload = await plain.json();
|
||||||
|
assert.equal(plainPayload.entropyApproximate, false);
|
||||||
|
assert.match(plainPayload.entropyNote, /exact/);
|
||||||
|
|
||||||
|
const memorable = await post(base, { mode: 'memorable', words: 4, digits: 2 });
|
||||||
|
const memorablePayload = await memorable.json();
|
||||||
|
assert.equal(memorablePayload.entropyApproximate, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SIGTERM stops the server quickly', async () => {
|
||||||
|
const port = 34567;
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${base}/healthz`);
|
||||||
|
if (response.ok) break;
|
||||||
|
} catch {
|
||||||
|
// not up yet
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw new Error('server did not start in time');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const exit = await new Promise((resolve) => {
|
||||||
|
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
});
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
|
||||||
|
assert.equal(exit.code, 0, `expected a clean exit, got code ${exit.code} signal ${exit.signal}`);
|
||||||
|
assert.ok(elapsed < 4000, `expected the shutdown to be quick, took ${elapsed}ms`);
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SIGINT stops the server quickly', async () => {
|
||||||
|
const port = 34568;
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${base}/healthz`);
|
||||||
|
if (response.ok) break;
|
||||||
|
} catch {
|
||||||
|
// not up yet
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw new Error('server did not start in time');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const exit = await new Promise((resolve) => {
|
||||||
|
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||||
|
child.kill('SIGINT');
|
||||||
|
});
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
|
||||||
|
assert.equal(exit.code, 0, `expected a clean exit, got code ${exit.code} signal ${exit.signal}`);
|
||||||
|
assert.ok(elapsed < 4000, `expected the shutdown to be quick, took ${elapsed}ms`);
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+291
-4
@@ -3,8 +3,13 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_SYMBOL_CHARS,
|
DEFAULT_SYMBOL_CHARS,
|
||||||
|
DIGIT_CHARS,
|
||||||
|
LOWER_CHARS,
|
||||||
|
MAX_CUSTOM_SYMBOLS,
|
||||||
|
UPPER_CHARS,
|
||||||
entropyForMemorizable,
|
entropyForMemorizable,
|
||||||
entropyForRandom,
|
entropyForRandom,
|
||||||
|
entropyIsApproximate,
|
||||||
generateMemorizable,
|
generateMemorizable,
|
||||||
generateRandom,
|
generateRandom,
|
||||||
} from '../src/generator.js';
|
} from '../src/generator.js';
|
||||||
@@ -15,6 +20,9 @@ const UPPER = /[A-Z]/;
|
|||||||
const DIGIT = /[0-9]/;
|
const DIGIT = /[0-9]/;
|
||||||
const SYMBOL = /[^a-zA-Z0-9]/;
|
const SYMBOL = /[^a-zA-Z0-9]/;
|
||||||
|
|
||||||
|
/** Matches an unpaired UTF-16 surrogate, i.e. half of an astral character. */
|
||||||
|
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||||
|
|
||||||
test('word lists are loaded and expose the expected sizes', () => {
|
test('word lists are loaded and expose the expected sizes', () => {
|
||||||
assert.deepEqual(listIds().sort(), ['eff-large', 'eff-short']);
|
assert.deepEqual(listIds().sort(), ['eff-large', 'eff-short']);
|
||||||
assert.equal(listSize('eff-large'), 7776);
|
assert.equal(listSize('eff-large'), 7776);
|
||||||
@@ -46,9 +54,9 @@ test('random mode honours each enabled class', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(value.length, 32);
|
assert.equal(value.length, 32);
|
||||||
assert.match(value, /[a-z]/);
|
assert.match(value, LOWER);
|
||||||
assert.match(value, /[A-Z]/);
|
assert.match(value, UPPER);
|
||||||
assert.match(value, /[0-9]/);
|
assert.match(value, DIGIT);
|
||||||
assert.match(value, SYMBOL);
|
assert.match(value, SYMBOL);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +185,7 @@ test('memorizable output applies capitalization', () => {
|
|||||||
|
|
||||||
assert.match(none, /^[a-z]+(-[a-z]+)*$/);
|
assert.match(none, /^[a-z]+(-[a-z]+)*$/);
|
||||||
assert.match(title, /^[A-Z][a-z]*(-[a-z]+)*$/);
|
assert.match(title, /^[A-Z][a-z]*(-[a-z]+)*$/);
|
||||||
assert.match(upper, /^[A-Z]+(-[A-Z]+)*$/);
|
assert.match(upper, /^[A-Z]+(-[a-z]+)*$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('memorizable with digits=0 contains no digits', () => {
|
test('memorizable with digits=0 contains no digits', () => {
|
||||||
@@ -248,3 +256,282 @@ test('entropy helpers return sane numbers', () => {
|
|||||||
const shorter = entropyForMemorizable({ words: 4, digits: 0, list: 'eff-large' });
|
const shorter = entropyForMemorizable({ words: 4, digits: 0, list: 'eff-large' });
|
||||||
assert.ok(shorter < memorable);
|
assert.ok(shorter < memorable);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- bug 1: unicode symbols were split into UTF-16 code units ---
|
||||||
|
|
||||||
|
test('random mode never splits astral characters into lone surrogates', () => {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
const value = generateRandom({
|
||||||
|
length: 12,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: '😀',
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(!LONE_SURROGATE.test(value), `expected no lone surrogate in ${JSON.stringify(value)}`);
|
||||||
|
const codePoints = Array.from(value);
|
||||||
|
assert.equal(codePoints.length, 12, 'expected 12 code points');
|
||||||
|
for (const codePoint of codePoints) {
|
||||||
|
assert.equal(codePoint, '😀');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('random mode handles a mixed custom symbol set with astral characters', () => {
|
||||||
|
const value = generateRandom({
|
||||||
|
length: 16,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: '😀🎉a',
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(!LONE_SURROGATE.test(value), `expected no lone surrogate in ${JSON.stringify(value)}`);
|
||||||
|
assert.equal(Array.from(value).length, 16);
|
||||||
|
for (const codePoint of Array.from(value)) {
|
||||||
|
assert.ok(['😀', '🎉', 'a'].includes(codePoint), `unexpected code point ${codePoint}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('random mode entropy counts code points, not UTF-16 code units', () => {
|
||||||
|
const one = entropyForRandom({
|
||||||
|
length: 8,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: '😀',
|
||||||
|
});
|
||||||
|
const two = entropyForRandom({
|
||||||
|
length: 8,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: '😀🎉',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(one, 8 * Math.log2(1));
|
||||||
|
assert.equal(two, 8 * Math.log2(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- bug 2: boolean options treated garbage as true ---
|
||||||
|
|
||||||
|
test('boolean options accept the documented truthy and falsy spellings', () => {
|
||||||
|
for (const value of [true, 1, 'true', 'TRUE', '1', 'yes', 'Yes', 'on']) {
|
||||||
|
const generated = generateRandom({
|
||||||
|
length: 12,
|
||||||
|
useLower: value,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: false,
|
||||||
|
});
|
||||||
|
assert.match(generated, LOWER, `expected ${JSON.stringify(value)} to enable lowercase`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of [false, 0, 'false', 'FALSE', '0', 'no', 'No', 'off']) {
|
||||||
|
const generated = generateRandom({
|
||||||
|
length: 12,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: value,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: false,
|
||||||
|
});
|
||||||
|
assert.ok(!UPPER.test(generated), `expected ${JSON.stringify(value)} to disable uppercase`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boolean options reject unparseable values and name the option', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({ length: 12, useLower: 'maybe' }),
|
||||||
|
/useLower must be a boolean/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({ length: 12, useUpper: 'nope' }),
|
||||||
|
/useUpper must be a boolean/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({ length: 12, useDigits: 'off-ish' }),
|
||||||
|
/useDigits must be a boolean/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({ length: 12, requireEach: 'sure' }),
|
||||||
|
/requireEach must be a boolean/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({ length: 12, excludeAmbiguous: 2 }),
|
||||||
|
/excludeAmbiguous must be a boolean/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a falsy string really disables the set', () => {
|
||||||
|
const value = generateRandom({
|
||||||
|
length: 24,
|
||||||
|
useLower: 'no',
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(value.length, 24);
|
||||||
|
assert.ok(!LOWER.test(value), 'expected no lowercase characters');
|
||||||
|
assert.match(value, DIGIT);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- bug 3: custom symbol sets were not validated ---
|
||||||
|
|
||||||
|
test('custom symbol sets reject control characters and whitespace', () => {
|
||||||
|
for (const symbols of ['a\tb', 'a\nb', 'a b', 'a\rb', 'a\u0000b', 'a\u200bb']) {
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({
|
||||||
|
length: 12,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: symbols,
|
||||||
|
}),
|
||||||
|
/customSymbols must not contain control characters or whitespace/,
|
||||||
|
`expected ${JSON.stringify(symbols)} to be rejected`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom symbol sets are limited to 64 code points', () => {
|
||||||
|
const tooMany = 'xy'.repeat(MAX_CUSTOM_SYMBOLS);
|
||||||
|
assert.throws(
|
||||||
|
() => generateRandom({
|
||||||
|
length: 12,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: tooMany,
|
||||||
|
}),
|
||||||
|
/customSymbols must contain at most 64 characters/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const exactly = 'x'.repeat(MAX_CUSTOM_SYMBOLS);
|
||||||
|
const value = generateRandom({
|
||||||
|
length: 12,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: exactly,
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
assert.equal(value.length, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom symbol sets are de-duplicated', () => {
|
||||||
|
const value = generateRandom({
|
||||||
|
length: 40,
|
||||||
|
useLower: false,
|
||||||
|
useUpper: false,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: true,
|
||||||
|
customSymbols: '@#@#@@',
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(Array.from(value).length, 40);
|
||||||
|
for (const codePoint of Array.from(value)) {
|
||||||
|
assert.ok(['@', '#'].includes(codePoint), `unexpected code point ${codePoint}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- bug 4: random mode entropy ignored requireEach ---
|
||||||
|
|
||||||
|
test('random entropy with requireEach counts only passwords with every class', () => {
|
||||||
|
const options = {
|
||||||
|
length: 16,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: true,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: true,
|
||||||
|
requireEach: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const constrained = entropyForRandom(options);
|
||||||
|
const unconstrained = entropyForRandom({ ...options, requireEach: false });
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
constrained < unconstrained,
|
||||||
|
`expected ${constrained} to be below the unconstrained ${unconstrained}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Closed form for "at least one character of each enabled class", spelled
|
||||||
|
// out here independently of the implementation. The pool holds 87
|
||||||
|
// characters (26 lowercase, 26 uppercase, 10 digits, 25 symbols), so the
|
||||||
|
// number of strings of length L that touch every class is
|
||||||
|
// 87^L
|
||||||
|
// - (2*61^L + 77^L + 62^L) missing one class
|
||||||
|
// + (35^L + 2*51^L + 2*36^L + 52^L) missing two classes
|
||||||
|
// - (25^L + 2*10^L + 2*26^L) missing three classes
|
||||||
|
const L = 16;
|
||||||
|
const count = 87 ** L
|
||||||
|
- (2 * 61 ** L + 77 ** L + 62 ** L)
|
||||||
|
+ (35 ** L + 2 * 51 ** L + 2 * 36 ** L + 52 ** L)
|
||||||
|
- (25 ** L + 2 * 10 ** L + 2 * 26 ** L);
|
||||||
|
const expected = Math.log2(count);
|
||||||
|
assert.ok(Math.abs(constrained - expected) < 0.2, `expected ~${expected.toFixed(1)}, got ${constrained}`);
|
||||||
|
|
||||||
|
// The pool really is that big, and the unconstrained value is larger.
|
||||||
|
assert.equal(LOWER_CHARS.length, 26);
|
||||||
|
assert.equal(UPPER_CHARS.length, 26);
|
||||||
|
assert.equal(DIGIT_CHARS.length, 10);
|
||||||
|
assert.equal(DEFAULT_SYMBOL_CHARS.length, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('random entropy with requireEach matches the closed form for two and three classes', () => {
|
||||||
|
const two = entropyForRandom({
|
||||||
|
length: 16,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: true,
|
||||||
|
useDigits: false,
|
||||||
|
useSymbols: false,
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
// N = 52, classes of 26: 52^L - 2*26^L (the empty intersection contributes 0).
|
||||||
|
const expectedTwo = Math.log2(52 ** 16 - 2 * 26 ** 16);
|
||||||
|
assert.ok(Math.abs(two - expectedTwo) < 0.2, `expected ~${expectedTwo.toFixed(1)}, got ${two}`);
|
||||||
|
|
||||||
|
const three = entropyForRandom({
|
||||||
|
length: 16,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: true,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: false,
|
||||||
|
requireEach: true,
|
||||||
|
});
|
||||||
|
// N = 62, classes 26/26/10.
|
||||||
|
const expectedThree = Math.log2(
|
||||||
|
62 ** 16 - (2 * 36 ** 16 + 52 ** 16) + (10 ** 16 + 2 * 26 ** 16),
|
||||||
|
);
|
||||||
|
assert.ok(Math.abs(three - expectedThree) < 0.2, `expected ~${expectedThree.toFixed(1)}, got ${three}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entropy is flagged as approximate only when requireEach can bite', () => {
|
||||||
|
const many = {
|
||||||
|
length: 16,
|
||||||
|
useLower: true,
|
||||||
|
useUpper: true,
|
||||||
|
useDigits: true,
|
||||||
|
useSymbols: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(entropyIsApproximate('random', { ...many, requireEach: true }), true);
|
||||||
|
assert.equal(entropyIsApproximate('random', { ...many, requireEach: false }), false);
|
||||||
|
assert.equal(
|
||||||
|
entropyIsApproximate('random', { length: 16, useLower: true, useUpper: false, useDigits: false, useSymbols: false, requireEach: true }),
|
||||||
|
false,
|
||||||
|
'a single class makes requireEach a no-op',
|
||||||
|
);
|
||||||
|
assert.equal(entropyIsApproximate('memorable', { words: 4, digits: 2 }), false);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user