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:
2026-10-21 09:26:32 +00:00
parent dfe3e94aa4
commit 1965c6efaa
9 changed files with 793 additions and 74 deletions
+224
View File
@@ -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
View File
@@ -3,8 +3,13 @@ import assert from 'node:assert/strict';
import {
DEFAULT_SYMBOL_CHARS,
DIGIT_CHARS,
LOWER_CHARS,
MAX_CUSTOM_SYMBOLS,
UPPER_CHARS,
entropyForMemorizable,
entropyForRandom,
entropyIsApproximate,
generateMemorizable,
generateRandom,
} from '../src/generator.js';
@@ -15,6 +20,9 @@ const UPPER = /[A-Z]/;
const DIGIT = /[0-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', () => {
assert.deepEqual(listIds().sort(), ['eff-large', 'eff-short']);
assert.equal(listSize('eff-large'), 7776);
@@ -46,9 +54,9 @@ test('random mode honours each enabled class', () => {
});
assert.equal(value.length, 32);
assert.match(value, /[a-z]/);
assert.match(value, /[A-Z]/);
assert.match(value, /[0-9]/);
assert.match(value, LOWER);
assert.match(value, UPPER);
assert.match(value, DIGIT);
assert.match(value, SYMBOL);
});
@@ -177,7 +185,7 @@ test('memorizable output applies capitalization', () => {
assert.match(none, /^[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', () => {
@@ -248,3 +256,282 @@ test('entropy helpers return sane numbers', () => {
const shorter = entropyForMemorizable({ words: 4, digits: 0, list: 'eff-large' });
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);
});