Skip to content

Password Generator — TypeScript source

Generate cryptographically-random passwords with a CSPRNG using rejection sampling (no modulo bias). Shows live entropy in bits, a 5-tier strength meter, average offline-GPU crack time, and a Pro mode with the entropy formula, a crack-time-vs-length curve, and a 4-scenario attack table. Everything runs locally - nothing is sent anywhere.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * Password generation + entropy scoring - pure logic extracted from
 * PasswordGenerator.tsx so it is unit-testable independent of React/crypto I/O.
 * Generation still uses the Web Crypto CSPRNG (`crypto.getRandomValues`).
 */

export interface PasswordOptions {
  length: number;
  upper: boolean;
  lower: boolean;
  numbers: boolean;
  symbols: boolean;
  excludeAmbiguous: boolean;
}

export type StrengthVariant = 'danger' | 'accent' | 'success';

export interface StrengthTier {
  label: string;
  variant: StrengthVariant;
  segments: 1 | 2 | 3 | 4 | 5;
}

const SETS = {
  lower: 'abcdefghijklmnopqrstuvwxyz',
  upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
  numbers: '0123456789',
  symbols: '!@#$%^&*()-_=+[]{};:,.<>?/',
};

// Visually ambiguous characters removed when `excludeAmbiguous` is set.
const AMBIGUOUS = /[O0Il1|]/g;

/** Build the candidate charset from the selected option flags. */
export function buildCharset(o: PasswordOptions): string {
  let cs = '';
  if (o.lower) cs += SETS.lower;
  if (o.upper) cs += SETS.upper;
  if (o.numbers) cs += SETS.numbers;
  if (o.symbols) cs += SETS.symbols;
  if (o.excludeAmbiguous) cs = cs.replace(AMBIGUOUS, '');
  return cs;
}

/**
 * Map a stream of random uint32 draws to a uniform index in [0, n) via
 * rejection sampling. Pure: inject `draw` for testability (production passes
 * a CSPRNG draw). Removes the modulo bias of a plain `draw() % n`.
 */
export function unbiasedIndex(n: number, draw: () => number): number {
  if (n <= 0) return 0;
  const MAX = 0x100000000; // 2^32 (Uint32 range, exclusive)
  const limit = MAX - (MAX % n); // largest multiple of n ≤ 2^32
  let r: number;
  do {
    r = draw() >>> 0;
  } while (r >= limit);
  return r % n;
}

const csprngDraw = (): number => {
  const buf = new Uint32Array(1);
  crypto.getRandomValues(buf);
  return buf[0];
};

/** Generate a cryptographically-random, unbiased password of `o.length` chars. */
export function generatePassword(o: PasswordOptions): string {
  const cs = buildCharset(o);
  if (!cs || o.length < 1) return '';
  let out = '';
  for (let i = 0; i < o.length; i++) out += cs[unbiasedIndex(cs.length, csprngDraw)];
  return out;
}

/** Theoretical entropy in bits for a uniform-random password. */
export function entropyBits(length: number, charsetSize: number): number {
  if (length <= 0 || charsetSize <= 1) return 0;
  return length * Math.log2(charsetSize);
}

/** Classify bits into one of 5 tiers (1:1 with the 5 meter segments). */
export function strengthTier(bits: number): StrengthTier {
  if (bits >= 100) return { label: 'very strong', variant: 'success', segments: 5 };
  if (bits >= 70) return { label: 'strong', variant: 'success', segments: 4 };
  if (bits >= 45) return { label: 'fair', variant: 'accent', segments: 3 };
  if (bits >= 28) return { label: 'weak', variant: 'danger', segments: 2 };
  return { label: 'very weak', variant: 'danger', segments: 1 };
}

export interface AttackScenario {
  id: string;
  label: string;
  guessesPerSecond: number;
}

export const ATTACK_SCENARIOS: AttackScenario[] = [
  { id: 'online-throttled', label: 'online, throttled (100/h)', guessesPerSecond: 100 / 3600 },
  { id: 'online', label: 'online, no throttle (10/s)', guessesPerSecond: 10 },
  { id: 'offline-slow', label: 'offline, slow hash (10⁴/s)', guessesPerSecond: 1e4 },
  { id: 'offline-fast', label: 'offline, fast GPU (10¹⁰/s)', guessesPerSecond: 1e10 },
];

/** Average time to crack (seconds) = 2^(bits-1) / guessesPerSecond. */
export function crackTimeSeconds(bits: number, guessesPerSecond: number): number {
  return Math.pow(2, bits - 1) / guessesPerSecond;
}

// Each row: [factor, unit-name-AFTER-dividing]. Start from 'second'; dividing
// seconds by 60 yields minutes, by 60 again yields hours, etc. The unit label
// is the bucket you land in AFTER the division (NOT the one you leave).
// Misaligning labels (e.g. [60,'second']) is the classic off-by-one.
const CRACK_UNITS: [number, string][] = [
  [60, 'minute'],
  [60, 'hour'],
  [24, 'day'],
  [365, 'year'],
];

/** Human-readable span; collapses to order-of-magnitude beyond 10⁶ years. */
export function formatCrackTime(seconds: number): string {
  if (!Number.isFinite(seconds) || seconds < 0) return '-';
  if (seconds < 1) return '< 1 second';
  let val = seconds;
  let unit = 'second';
  for (const [factor, name] of CRACK_UNITS) {
    if (val < factor) break;
    val /= factor;
    unit = name;
  }
  if (unit === 'year' && val >= 1e6) return `10^${Math.round(Math.log10(val))} years`;
  const n = Math.round(val);
  return `${n.toLocaleString()} ${unit}${n === 1 ? '' : 's'}`;
}

/** Entropy bits per length across a range - the data for the crack-time-vs-length graph. */
export function entropyCurve(
  charsetSize: number,
  fromLength: number,
  toLength: number,
  step = 1,
): { length: number; bits: number }[] {
  const out: { length: number; bits: number }[] = [];
  for (let l = fromLength; l <= toLength; l += step) out.push({ length: l, bits: entropyBits(l, charsetSize) });
  return out;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →