diff --git a/README.md b/README.md index 27c206e..87661f1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ account, no login, no database, and nothing you generate is stored or logged. - 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 @@ -100,12 +102,21 @@ curl -s -X POST localhost:3000/api/generate \ ``` ```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`, -`customSymbols` (replaces the default symbol set when non-empty), `excludeAmbiguous` -(removes `0 O 1 l I |`) and `requireEach`. +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 \ @@ -114,15 +125,23 @@ curl -s -X POST localhost:3000/api/generate \ ``` ```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 -- Random mode: `length * log2(poolSize)`, where `poolSize` is the size of the union of all - enabled character classes. +- 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. + 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 @@ -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 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). diff --git a/docker-compose.yml b/docker-compose.yml index d3289b0..87e90d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,10 @@ services: app: build: . image: reze-test:latest - ports: - - "3000:3000" + # Only exposed to the container network: publishing the port on the host + # would collide with Dokploy's own port 3000. + expose: + - "3000" environment: PORT: 3000 restart: unless-stopped diff --git a/public/app.js b/public/app.js index c9760e0..ea80c86 100644 --- a/public/app.js +++ b/public/app.js @@ -239,12 +239,16 @@ const entropy = document.createElement('span'); 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 label = document.createElement('span'); label.className = 'pw-strength-label'; - label.textContent = STRENGTH_LABELS[level]; + label.textContent = STRENGTH_LABELS[level] + (approximate ? ' (approx.)' : ''); const copy = document.createElement('button'); copy.type = 'button'; @@ -269,18 +273,21 @@ function payloadFor(state) { if (state.mode === 'random') { - return { + var payload = { 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 }; + // 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 { mode: 'memorable', diff --git a/public/index.html b/public/index.html index 681c712..118ca98 100644 --- a/public/index.html +++ b/public/index.html @@ -98,6 +98,10 @@

Output

+

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.

+
diff --git a/public/style.css b/public/style.css index 77dbebe..06bb0f4 100644 --- a/public/style.css +++ b/public/style.css @@ -104,6 +104,8 @@ main.error p { color: var(--muted); margin: 0 0 2rem; } .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; } .radio, diff --git a/server.js b/server.js index 79ec58f..0f5c791 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,10 @@ -import { createServer } from 'node:http'; +import { createServer as createHttpServer } from 'node:http'; import { readFileSync, statSync, realpathSync } from 'node:fs'; import { extname, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { + entropyIsApproximate, estimateEntropy, generateMemorizable, generateRandom, @@ -15,6 +16,7 @@ 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 SHUTDOWN_GRACE_MS = 5000; const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', @@ -200,8 +202,10 @@ async function handleGenerate(req, res, body) { } let entropy; + let approximate; try { entropy = estimateEntropy(mode, body); + approximate = entropyIsApproximate(mode, body); } catch (err) { sendError(req, res, 400, err.message); return; @@ -216,10 +220,19 @@ async function handleGenerate(req, res, body) { sendError(req, res, 400, err.message); 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) { @@ -251,39 +264,81 @@ async function handleApi(req, res, pathname) { 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; - } +function createRequestHandler() { + return (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 (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'); + 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); }); - return; - } + 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(); + }; - if (req.method !== 'GET' && req.method !== 'HEAD') { - sendError(req, res, 405, 'method not allowed'); - return; - } + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); - serveStatic(req, res, pathname); -}); + server.listen(port, host, () => { + console.log(`reze-test listening on http://${host}:${port}`); + }); -server.listen(PORT, HOST, () => { - console.log(`reze-test listening on http://${HOST}:${PORT}`); -}); + return server; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + startServer(); +} diff --git a/src/generator.js b/src/generator.js index a7b7b23..f754c97 100644 --- a/src/generator.js +++ b/src/generator.js @@ -12,6 +12,12 @@ export const GROUP_SEPARATORS = ['-', '_', '.', ' ', '']; export const DIGIT_SEPARATORS = ['', '-']; 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 = { words: 4, digits: 2, @@ -55,12 +61,23 @@ function enumOption(value, fallback, allowed, name) { 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 (value === 'true' || value === '1' || value === 1) return true; - if (value === 'false' || value === '0' || value === 0) return false; - return Boolean(value); + if (typeof value === 'number') { + if (value === 1) return true; + 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) { @@ -83,6 +100,39 @@ function applyCapitalization(word, capitalization) { 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) { const words = intOption(opts.words, MEMORABLE_DEFAULTS.words, 2, 10, 'words'); const digits = intOption(opts.digits, MEMORABLE_DEFAULTS.digits, 0, 4, 'digits'); @@ -112,13 +162,17 @@ function normalizeMemorizable(opts) { 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); + const useLower = boolOption(opts.useLower, RANDOM_DEFAULTS.useLower, 'useLower'); + const useUpper = boolOption(opts.useUpper, RANDOM_DEFAULTS.useUpper, 'useUpper'); + const useDigits = boolOption(opts.useDigits, RANDOM_DEFAULTS.useDigits, 'useDigits'); + const useSymbols = boolOption(opts.useSymbols, RANDOM_DEFAULTS.useSymbols, 'useSymbols'); + const customSymbols = normalizeCustomSymbols(opts.customSymbols); + const excludeAmbiguous = boolOption( + opts.excludeAmbiguous, + RANDOM_DEFAULTS.excludeAmbiguous, + 'excludeAmbiguous', + ); + const requireEach = boolOption(opts.requireEach, RANDOM_DEFAULTS.requireEach, 'requireEach'); return { 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) { 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.useLower) classes.push({ name: 'lowercase', chars: Array.from(LOWER_CHARS) }); + if (options.useUpper) classes.push({ name: 'uppercase', chars: Array.from(UPPER_CHARS) }); + if (options.useDigits) classes.push({ name: 'digits', chars: Array.from(DIGIT_CHARS) }); if (options.useSymbols) { 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) { for (const cls of classes) { - cls.chars = Array.from(cls.chars) - .filter((ch) => !AMBIGUOUS_CHARS.includes(ch)) - .join(''); + cls.chars = cls.chars.filter((ch) => !AMBIGUOUS_CHARS.includes(ch)); } } + const union = []; 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; + union.push(ch); } } } @@ -175,6 +231,31 @@ function shuffle(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 = {}) { const options = normalizeMemorizable(opts); const words = getWordList(options.list); @@ -204,12 +285,12 @@ export function generateRandom(opts = {}) { for (const cls of classes) { if (chars.length >= options.length) break; if (cls.chars.length === 0) continue; - chars.push(pick(cls.chars.split(''))); + chars.push(pick(cls.chars)); } } while (chars.length < options.length) { - chars.push(union[randomInt(union.length)]); + chars.push(pick(union)); } return shuffle(chars).join(''); @@ -224,12 +305,42 @@ export function entropyForMemorizable(opts = {}) { export function entropyForRandom(opts = {}) { const options = normalizeRandom(opts); - const { union } = buildPools(options); + const { classes, 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); + + 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 = {}) { diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..8450ee8 --- /dev/null +++ b/test/api.test.js @@ -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])|(? { + 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'); + } + } +}); diff --git a/test/generator.test.js b/test/generator.test.js index a0a202a..7953b08 100644 --- a/test/generator.test.js +++ b/test/generator.test.js @@ -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])|(? { 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); +});