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.
538 lines
16 KiB
JavaScript
538 lines
16 KiB
JavaScript
import test from 'node:test';
|
|
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';
|
|
import { getWordList, listIds, listSize } from '../src/words.js';
|
|
|
|
const LOWER = /[a-z]/;
|
|
const UPPER = /[A-Z]/;
|
|
const DIGIT = /[0-9]/;
|
|
const SYMBOL = /[^a-zA-Z0-9]/;
|
|
|
|
/** 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);
|
|
assert.equal(listSize('eff-short'), 1296);
|
|
assert.equal(getWordList('eff-large').length, 7776);
|
|
assert.throws(() => getWordList('nope'), /unknown word list/);
|
|
});
|
|
|
|
test('random mode respects the requested length and only uses enabled classes', () => {
|
|
const value = generateRandom({
|
|
length: 24,
|
|
useLower: true,
|
|
useUpper: false,
|
|
useDigits: false,
|
|
useSymbols: false,
|
|
});
|
|
|
|
assert.equal(value.length, 24);
|
|
assert.match(value, /^[a-z]+$/);
|
|
});
|
|
|
|
test('random mode honours each enabled class', () => {
|
|
const value = generateRandom({
|
|
length: 32,
|
|
useLower: true,
|
|
useUpper: true,
|
|
useDigits: true,
|
|
useSymbols: true,
|
|
});
|
|
|
|
assert.equal(value.length, 32);
|
|
assert.match(value, LOWER);
|
|
assert.match(value, UPPER);
|
|
assert.match(value, DIGIT);
|
|
assert.match(value, SYMBOL);
|
|
});
|
|
|
|
test('random mode with requireEach contains at least one char from each enabled class', () => {
|
|
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
const value = generateRandom({
|
|
length: 16,
|
|
useLower: true,
|
|
useUpper: true,
|
|
useDigits: true,
|
|
useSymbols: true,
|
|
requireEach: true,
|
|
});
|
|
|
|
assert.equal(value.length, 16);
|
|
assert.match(value, LOWER);
|
|
assert.match(value, UPPER);
|
|
assert.match(value, DIGIT);
|
|
assert.match(value, SYMBOL);
|
|
}
|
|
});
|
|
|
|
test('random mode uses the custom symbol set instead of the default one', () => {
|
|
const value = generateRandom({
|
|
length: 20,
|
|
useLower: true,
|
|
useUpper: false,
|
|
useDigits: false,
|
|
useSymbols: true,
|
|
customSymbols: '@#',
|
|
requireEach: true,
|
|
});
|
|
|
|
assert.match(value, /^[a-z@#]+$/);
|
|
assert.match(value, /[@#]/);
|
|
assert.ok(!value.includes('!'));
|
|
assert.equal(DEFAULT_SYMBOL_CHARS.includes('!'), true);
|
|
});
|
|
|
|
test('random mode excludes ambiguous characters on request', () => {
|
|
const value = generateRandom({
|
|
length: 60,
|
|
useLower: true,
|
|
useUpper: true,
|
|
useDigits: true,
|
|
useSymbols: true,
|
|
excludeAmbiguous: true,
|
|
});
|
|
|
|
assert.equal(value.length, 60);
|
|
for (const ch of ['0', 'O', '1', 'l', 'I', '|']) {
|
|
assert.ok(!value.includes(ch), `expected ${ch} to be excluded`);
|
|
}
|
|
});
|
|
|
|
test('random mode throws when every set is disabled', () => {
|
|
assert.throws(
|
|
() => generateRandom({
|
|
length: 16,
|
|
useLower: false,
|
|
useUpper: false,
|
|
useDigits: false,
|
|
useSymbols: false,
|
|
}),
|
|
/no characters available/,
|
|
);
|
|
});
|
|
|
|
test('memorizable output has the requested groups, digits and words', () => {
|
|
const words = getWordList('eff-large');
|
|
const value = generateMemorizable({
|
|
words: 3,
|
|
digits: 2,
|
|
digitSeparator: '',
|
|
separator: '_',
|
|
capitalization: 'none',
|
|
list: 'eff-large',
|
|
});
|
|
|
|
assert.match(value, /^[0-9a-zA-Z]*([\-_. ]?[0-9a-zA-Z]+)*$/);
|
|
|
|
const groups = value.split('_');
|
|
assert.equal(groups.length, 3);
|
|
|
|
for (const group of groups) {
|
|
assert.match(group, /^\d{2}[a-z]+(-[a-z]+)*$/);
|
|
const word = group.slice(2);
|
|
assert.ok(words.includes(word), `expected "${word}" to come from the word list`);
|
|
}
|
|
});
|
|
|
|
test('memorizable output supports every group separator', () => {
|
|
for (const separator of ['_', '.', ' ']) {
|
|
const value = generateMemorizable({
|
|
words: 2,
|
|
digits: 0,
|
|
separator,
|
|
capitalization: 'none',
|
|
list: 'eff-short',
|
|
});
|
|
assert.equal(value.split(separator).length, 2);
|
|
}
|
|
});
|
|
|
|
test('memorizable output applies the digit separator', () => {
|
|
const value = generateMemorizable({
|
|
words: 2,
|
|
digits: 3,
|
|
digitSeparator: '-',
|
|
separator: '_',
|
|
capitalization: 'none',
|
|
list: 'eff-short',
|
|
});
|
|
|
|
const groups = value.split('_');
|
|
assert.equal(groups.length, 2);
|
|
for (const group of groups) {
|
|
assert.match(group, /^\d{3}-[a-z]+(-[a-z]+)*$/);
|
|
}
|
|
});
|
|
|
|
test('memorizable output applies capitalization', () => {
|
|
const none = generateMemorizable({ words: 2, digits: 0, capitalization: 'none', list: 'eff-short' }).split('-')[0];
|
|
const title = generateMemorizable({ words: 2, digits: 0, capitalization: 'title', list: 'eff-short' }).split('-')[0];
|
|
const upper = generateMemorizable({ words: 2, digits: 0, capitalization: 'upper', list: 'eff-short' }).split('-')[0];
|
|
|
|
assert.match(none, /^[a-z]+(-[a-z]+)*$/);
|
|
assert.match(title, /^[A-Z][a-z]*(-[a-z]+)*$/);
|
|
assert.match(upper, /^[A-Z]+(-[a-z]+)*$/);
|
|
});
|
|
|
|
test('memorizable with digits=0 contains no digits', () => {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const value = generateMemorizable({
|
|
words: 5,
|
|
digits: 0,
|
|
separator: '-',
|
|
capitalization: 'none',
|
|
list: 'eff-large',
|
|
});
|
|
assert.ok(!/[0-9]/.test(value), 'expected no digits');
|
|
assert.equal(value.split('-').length, 5);
|
|
}
|
|
});
|
|
|
|
test('memorizable rejects unknown word lists and out-of-range options', () => {
|
|
assert.throws(
|
|
() => generateMemorizable({ words: 2, digits: 2, list: 'nope' }),
|
|
/unknown word list/,
|
|
);
|
|
assert.throws(
|
|
() => generateMemorizable({ words: 99, digits: 2 }),
|
|
/words must be between 2 and 10/,
|
|
);
|
|
assert.throws(
|
|
() => generateMemorizable({ words: 2, digits: 9 }),
|
|
/digits must be between 0 and 4/,
|
|
);
|
|
});
|
|
|
|
test('two consecutive calls differ', () => {
|
|
const a = generateRandom({ length: 24, useLower: true, useUpper: true, useDigits: true, useSymbols: true });
|
|
const b = generateRandom({ length: 24, useLower: true, useUpper: true, useDigits: true, useSymbols: true });
|
|
assert.notEqual(a, b);
|
|
|
|
const m1 = generateMemorizable({ words: 4, digits: 2, separator: '-', capitalization: 'title' });
|
|
const m2 = generateMemorizable({ words: 4, digits: 2, separator: '-', capitalization: 'title' });
|
|
assert.notEqual(m1, m2);
|
|
});
|
|
|
|
test('entropy helpers return sane numbers', () => {
|
|
const short = entropyForRandom({ length: 8, useLower: true });
|
|
const long = entropyForRandom({ length: 64, useLower: true });
|
|
|
|
assert.ok(short > 0, 'expected positive entropy');
|
|
assert.ok(long > short, 'expected entropy to grow with length');
|
|
assert.equal(short, Math.round(short * 10) / 10);
|
|
assert.equal(long, Math.round(long * 10) / 10);
|
|
|
|
const symbols = entropyForRandom({
|
|
length: 8,
|
|
useLower: true,
|
|
useUpper: true,
|
|
useDigits: true,
|
|
useSymbols: true,
|
|
});
|
|
assert.ok(symbols > short);
|
|
|
|
const memorable = entropyForMemorizable({ words: 4, digits: 2, list: 'eff-large' });
|
|
const expected = 4 * Math.log2(7776) + 4 * 2 * Math.log2(10);
|
|
assert.equal(memorable, Math.round(expected * 10) / 10);
|
|
assert.ok(memorable > 0);
|
|
|
|
const moreWords = entropyForMemorizable({ words: 5, digits: 2, list: 'eff-large' });
|
|
assert.ok(moreWords > memorable);
|
|
|
|
const shorter = entropyForMemorizable({ words: 4, digits: 0, list: 'eff-large' });
|
|
assert.ok(shorter < memorable);
|
|
});
|
|
|
|
// --- 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);
|
|
});
|