runlocally

runlocally engineering notes

Password Generator

How Password Generator is built

By Geppetto · · Open Password Generator →

Password Generator builds passwords in the browser from a character set you choose. This post covers the random-number source behind every character it picks, the algorithm that keeps that mapping unbiased, the logic that guarantees a chosen character class actually shows up in the result, and the entropy figure the tool reports.

Tech used

crypto.getRandomValues, and why not Math.random

Math.random() is a pseudo-random generator tuned for speed and statistical spread, not for resisting prediction — nothing about its spec requires that past or future outputs be unguessable, and browser implementations are free to use algorithms (xorshift128+ historically, in some engines) whose internal state can be recovered from a handful of samples. That makes it unsuitable for anything where an adversary benefits from predicting the next value, which describes a generated password exactly.

The Web Crypto API’s crypto.getRandomValues() is the browser-exposed interface to a cryptographically secure pseudo-random number generator (CSPRNG) — the same class of source the operating system uses to generate encryption keys. src/lib/passwordEngine.ts is the only file in the tool that calls it; every other module that needs randomness goes through this file’s randomInt/randomChar functions rather than touching Math.random or crypto.getRandomValues directly. A unit test enforces the boundary concretely: it spies on Math.random and asserts zero calls across 500 draws from randomInt.

Modulo bias, and rejection sampling as the fix

crypto.getRandomValues() hands back raw bytes — integers in [0, 256). Turning a byte into “pick one of N characters” sounds like it’s just byte % N, but that introduces a real, measurable bias whenever 256 % N !== 0, which is true for almost any N that isn’t a power of two. The default combined charset here is 94 characters (26 lowercase + 26 uppercase + 10 digits + 32 symbols): 256 % 94 = 68, so the 68 byte values from 188 to 255 each land on a charset index that a byte from 0-187 also maps to, while the rest only get hit once. Under naive modulo, the first 68 characters in the set would be drawn with slightly higher probability than the last 26 — a small bias, but a real and reproducible one, not a theoretical footnote.

randomInt(max) in passwordEngine.ts avoids this with rejection sampling instead of reduction:

export function randomInt(max: number): number {
  if (!Number.isInteger(max) || max <= 0 || max > 256) {
    throw new RangeError('randomInt: max must be an integer in (0, 256]');
  }
  const limit = 256 - (256 % max);
  const buf = new Uint8Array(1);
  let byte: number;
  do {
    crypto.getRandomValues(buf);
    byte = buf[0];
  } while (byte >= limit);
  return byte % max;
}

limit is the largest multiple of max that fits under 256. A drawn byte at or above limit falls in the biased leftover tail, so it’s discarded and a fresh byte is drawn — it never gets reduced with %. Every byte that survives lands in [0, limit), which divides evenly into max-sized buckets, so byte % max over the accepted bytes is uniform. randomChar(chars) is a one-line wrapper: chars[randomInt(chars.length)]. The test suite checks this at three levels — a mocked-bytes test where a byte in the known-biased tail (253, for max = 6) is fed in first and asserted to be discarded rather than reduced; an exhaustive version of the same check for max = 94; and a 200,000-sample statistical spot-check for max = 10 with a deliberately loose ±15% tolerance per bucket, chosen to catch gross bias without making CI flaky on a legitimate tail-probability outlier.

Guaranteed character-class coverage: draw first, patch only what’s missing

Enabling multiple character classes doesn’t guarantee all of them appear in an unconstrained draw — at length 8 with all four classes on, a symbol-free result is a normal (if infrequent) outcome of fair sampling, not a bug. generatePassword handles this in two passes rather than one. First, it draws the entire password by calling randomChar against the full combined pool (all enabled classes concatenated) once per position — no character class gets special treatment in this pass. Second, it checks which enabled classes ended up with zero representatives, and for each one, overwrites a single position with a fresh randomChar draw from that class’s pool.

This is a different scheme from either of the two more common approaches: it isn’t “force one character from each enabled class up front, fill the rest freely, then shuffle,” which spends a fixed number of draws on guaranteed placement regardless of whether the unconstrained draw would have produced them anyway; and it isn’t “generate the whole password and reject-and-retry until the constraint happens to hold,” which can loop an unbounded (if typically small) number of times. Patching only the classes that actually came up empty means the vast majority of positions in a typical password are exactly the product of the unconstrained draw, with correction applied only where it’s demonstrably needed.

The patch step has a subtlety the code calls out directly in a comment: if two classes are both missing (routine at the 8-character minimum with several classes enabled), picking an independent random position for each fix can pick a position holding the only occurrence of some third, already-satisfied class — silently breaking a guarantee that was already met. The fix computes, from the original draw, which positions are the sole occurrence of a non-missing class, marks those “protected,” and only assigns fix positions (also chosen via randomInt, so still unbiased) that are neither already used by another fix nor protected. The loop that picks a fix position is bounded (guard < chars.length * 4) rather than unconditional; a comment explains why that’s safe rather than just defensive — with the enforced minimum length of 8 and at most 4 character classes, the set of unavailable (used-or-protected) positions can never reach the full length, so the loop always finds a valid position quickly, and the guard exists only so a future change to those constants fails by reusing a position instead of hanging.

Entropy: length × log2(charset size), not a strength meter

calculateEntropyBits(length, charsetSize) returns length * Math.log2(charsetSize), falling back to 0 for an empty or single-character charset. charsetSize comes from effectiveCharsetSize, which sums the sizes of the enabled, ambiguous-filtered per-class pools. For the default settings — 16 characters, all four classes on, ambiguous exclusion off — that’s 16 * log2(94) ≈ 104.7 bits; turning on “exclude ambiguous characters” drops the pool to 89 (94 minus the fixed five look-alikes) and the figure with it, to 16 * log2(89) ≈ 103.1 bits.

The number is exactly the theoretical bound for “draw length characters independently and uniformly from a pool of charsetSize” — a plain, checkable measurement, not an estimate tuned to look reassuring. The UI renders it as a sentence of text (t.entropySummary, filled in with length, charset size, and the bits figure) inside a plain <p role="status">, with no color, no bar, and no discrete weak/medium/strong label anywhere in the component. The class-coverage patch described above doesn’t get its own accounting in this number — the doc comment in passwordEngine.ts notes that the reported figure stays at the same theoretical bound the unconstrained-draw scheme would report, and treats that as the same tradeoff any generator that promises “at least one of each” makes, rather than claiming the patch is entropy-neutral.

Implementation & operational notes

The ambiguous-character set is fixed, not configurable. “Exclude ambiguous characters” removes exactly five characters — 0, O, l, 1, I — split as two from uppercase, one from lowercase, two from digits, zero from symbols. buildCharPools filters this set out of each enabled class’s pool but still checks the result isn’t empty before including the class; with only 26-32 characters per class and at most 2 removed, that check never actually trips today, but the code doesn’t assume it.

Nothing is persisted. The component that drives generation keeps passwords only in in-memory Preact state — no localStorage, no history list. A page refresh or navigation loses every password that wasn’t copied. That’s stated as a deliberate choice in the component’s own doc comment, not an oversight: for something this sensitive, the default is that the browser forgets.

No Web Worker. astro.config.mjs records the reasoning directly: generating a handful of short strings via crypto.getRandomValues and rejection sampling is lightweight, synchronous CPU work, so it runs straight on the main thread rather than being offloaded — the same category of decision sibling tools in this catalog make about when a Worker is and isn’t worth the complexity.

Count is clamped, and each password is an independent draw. generatePasswords(options, count) clamps the requested count into [MIN_COUNT, MAX_COUNT] (1-20) and calls generatePassword once per slot — there’s no shared state or derivation between the passwords in one batch beyond using the same settings.

Try it / source

Password Generator

Open the tool → All posts →