reze: Add Node.js password generator with memorable and random modes
Public password generator with no auth and no npm dependencies. - Server-side generation using crypto.randomInt; two levels: diceware-style memorable passphrases (42abacus-91abdomen) and fully random characters. - Configurable word list (EFF Large / EFF Short), digits per word, separators, capitalization, length, character class toggles, custom symbols and ambiguous-character exclusion. - Light/dark theme matching druid520/site, entropy estimate and strength bar, clipboard copy, options persisted in localStorage. - Dockerfile for Dokploy plus a Woodpecker deploy hook on trunk.
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
DEFAULT_SYMBOL_CHARS,
|
||||
entropyForMemorizable,
|
||||
entropyForRandom,
|
||||
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]/;
|
||||
|
||||
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, /[a-z]/);
|
||||
assert.match(value, /[A-Z]/);
|
||||
assert.match(value, /[0-9]/);
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user