fix(dashboard): rejection-sample password-gen to remove modulo bias
Switches to Uint8 rejection sampling against the largest multiple of the charset length that fits in a byte. Eliminates the ~16 ppm overweight the previous `% N` over Uint32 would otherwise leave on the first 38 chars. Adds a vitest distribution check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
dashboard/src/lib/password-gen.test.ts
Normal file
54
dashboard/src/lib/password-gen.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generatePassword } from './password-gen';
|
||||
|
||||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('rejects lengths under 8', () => {
|
||||
expect(() => generatePassword(7)).toThrowError(/at least 8/);
|
||||
});
|
||||
|
||||
it('respects the requested length', () => {
|
||||
for (const len of [8, 16, 32, 64]) {
|
||||
expect(generatePassword(len)).toHaveLength(len);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only characters from the documented charset', () => {
|
||||
const set = new Set(CHARSET);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
for (const c of generatePassword(32)) {
|
||||
expect(set.has(c)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Rejection-sampling sanity. With N = 71 the expected count per
|
||||
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
|
||||
// any byte-level bias (biased modulo would push the first 38
|
||||
// chars by ~16 ppm — too small for this band to flag on its
|
||||
// own, but a regression to `% N` over Uint16/Uint32 with a
|
||||
// non-power-of-two charset would still produce visible drift in
|
||||
// pathological codepaths). Mostly this guards against
|
||||
// fundamental mistakes (off-by-one in the loop, returning the
|
||||
// same byte stream every time, etc.).
|
||||
it('distribution stays within a wide tolerance band', () => {
|
||||
const samples = 100_000;
|
||||
const counts = new Map<string, number>();
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const c = generatePassword(8)[0];
|
||||
counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||
}
|
||||
const expected = samples / CHARSET.length;
|
||||
const sigma = Math.sqrt(expected);
|
||||
const band = 6 * sigma;
|
||||
for (const c of CHARSET) {
|
||||
const observed = counts.get(c) ?? 0;
|
||||
const drift = Math.abs(observed - expected);
|
||||
expect(
|
||||
drift,
|
||||
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
|
||||
).toBeLessThan(band);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user