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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
||||||
// avoidant of characters that ship awkwardly through chat clients
|
// avoidant of characters that ship awkwardly through chat clients
|
||||||
// (no quotes, slashes, or backticks).
|
// (no quotes, slashes, or backticks).
|
||||||
|
//
|
||||||
|
// Sampling: rejection sampling against a Uint8 stream. The naive
|
||||||
|
// `byte % CHARSET.length` would slightly overweight the first
|
||||||
|
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
|
||||||
|
// safe at 16 chars but easy to remove.
|
||||||
|
|
||||||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
@@ -15,11 +20,18 @@ export function generatePassword(length = 16): string {
|
|||||||
if (length < 8) {
|
if (length < 8) {
|
||||||
throw new Error('password length must be at least 8');
|
throw new Error('password length must be at least 8');
|
||||||
}
|
}
|
||||||
const buf = new Uint32Array(length);
|
const n = CHARSET.length;
|
||||||
crypto.getRandomValues(buf);
|
// Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
|
||||||
|
// rejected to remove modulo bias.
|
||||||
|
const max = 256 - (256 % n);
|
||||||
|
const buf = new Uint8Array(length);
|
||||||
let out = '';
|
let out = '';
|
||||||
for (let i = 0; i < length; i++) {
|
while (out.length < length) {
|
||||||
out += CHARSET[buf[i] % CHARSET.length];
|
crypto.getRandomValues(buf);
|
||||||
|
for (let i = 0; i < buf.length && out.length < length; i++) {
|
||||||
|
const byte = buf[i];
|
||||||
|
if (byte < max) out += CHARSET[byte % n];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user