Passphrase Generator — TypeScript source
Generate memorable, high-entropy passphrases from the EFF diceware wordlist using cryptographic randomness. Each word is drawn with crypto.getRandomValues and rejection sampling (no modulo bias), and every passphrase shows its exact entropy in bits plus the estimated crack time at 1 trillion guesses/sec. Everything runs locally - nothing is sent anywhere.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
/**
* Passphrase generation + entropy scoring - pure logic extracted from
* PassphraseGenerator.tsx so it is unit-testable independent of React.
* Word selection uses the Web Crypto CSPRNG (`crypto.getRandomValues`) with
* rejection sampling, so every word index is uniform over the 7,776-word EFF
* long wordlist - no modulo bias.
*/
import { EFF_WORDLIST } from './eff-wordlist';
export type Separator = 'space' | 'dash' | 'dot' | 'underscore' | 'none';
export interface PassphraseOptions {
wordCount?: number;
separator?: Separator;
capitalize?: boolean;
appendDigit?: boolean;
/** Attack speed for the crack-time estimate. Default 1 trillion guesses/s. */
guessesPerSecond?: number;
}
export interface CrackTimeEstimate {
seconds: number;
human: string;
}
export interface Passphrase {
words: string[];
passphrase: string;
entropy: number;
crackTime: CrackTimeEstimate;
}
/** Size of the EFF long wordlist (6^5). */
export const EFF_WORDLIST_LENGTH = 7776;
export const MIN_WORDS = 3;
export const MAX_WORDS = 10;
export const DEFAULT_WORDS = 6;
export const DEFAULT_GUESSES_PER_SECOND = 1e12;
const SEPARATORS: Record<Separator, string> = {
space: ' ',
dash: '-',
dot: '.',
underscore: '_',
none: '',
};
// Rejection-sampling ceiling for a uniform uint16 over 7,776: the largest
// multiple of 7,776 that fits in [0, 65536). 7776 x 8 = 62208; draws >= 62208
// are rejected so `v % 7776` is uniform (a plain `% 7776` would favor the
// first 65536 % 7776 = 2048 indexes).
const WORD_LIMIT = EFF_WORDLIST_LENGTH * Math.floor(65536 / EFF_WORDLIST_LENGTH);
/** Draw one uniformly-random word from the EFF long wordlist (CSPRNG). */
function randomWord(): string {
const buf = new Uint16Array(1);
let v: number;
do {
crypto.getRandomValues(buf);
v = buf[0];
} while (v >= WORD_LIMIT);
return EFF_WORDLIST[v % EFF_WORDLIST_LENGTH];
}
/** Draw one uniformly-random digit 0-9 (CSPRNG, rejection-sampled on uint8). */
function randomDigit(): string {
const buf = new Uint8Array(1);
let v: number;
do {
crypto.getRandomValues(buf);
v = buf[0];
} while (v >= 250); // largest multiple of 10 in [0, 256)
return String(v % 10);
}
/** Theoretical word entropy in bits: wordCount x log2(7776). */
export function entropyBits(wordCount: number): number {
if (wordCount <= 0) return 0;
return wordCount * Math.log2(EFF_WORDLIST_LENGTH);
}
const YEAR = 31_557_600; // seconds (Julian year)
const SCALES: [number, string][] = [
[1e12, 'trillion'],
[1e9, 'billion'],
[1e6, 'million'],
[1e3, 'thousand'],
];
/**
* Human-readable crack-time span. Uses centuries/millennia past a year,
* then collapses to scaled words ("2.4 billion centuries") so the string
* stays readable at diceware entropies (60-130 bits).
*/
export function formatCrackTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '-';
if (seconds < 1) return '< 1 second';
if (seconds < 60) return span(seconds, 'second');
if (seconds < 3600) return span(seconds / 60, 'minute');
if (seconds < 86_400) return span(seconds / 3600, 'hour');
if (seconds < YEAR) return span(seconds / 86_400, 'day');
const years = seconds / YEAR;
if (years < 100) return span(years, 'year');
if (years < 1000) return span(years / 100, 'century', 'centuries');
if (years < 1e6) return span(years / 1000, 'millennium', 'millennia');
// years >= 1e6 here, so centuries >= 10,000: collapse to a scaled plural.
const centuries = years / 100;
const [factor, name] = SCALES.find(([f]) => centuries >= f)!;
return `${round(centuries / factor).toLocaleString()} ${name} centuries`;
}
/** Round + pluralize `v unit`; 1 decimal below 10, whole numbers above. */
function span(v: number, singular: string, plural = `${singular}s`): string {
const n = round(v);
return `${n.toLocaleString()} ${n === 1 ? singular : plural}`;
}
function round(v: number): number {
return v >= 10 ? Math.round(v) : Math.round(v * 10) / 10;
}
/** Average time to crack (seconds) = 2^(bits-1) / guessesPerSecond. */
export function crackTimeSeconds(bits: number, guessesPerSecond: number): number {
return Math.pow(2, bits - 1) / guessesPerSecond;
}
/** Generate a diceware passphrase from the EFF long wordlist. */
export function generatePassphrase(options: PassphraseOptions = {}): Passphrase {
const raw = options.wordCount ?? DEFAULT_WORDS;
const wordCount = clamp(
Number.isFinite(raw) ? Math.round(raw) : DEFAULT_WORDS,
MIN_WORDS,
MAX_WORDS
);
const separator = SEPARATORS[options.separator ?? 'space'];
const capitalize = options.capitalize ?? false;
const appendDigit = options.appendDigit ?? false;
const gps = options.guessesPerSecond ?? DEFAULT_GUESSES_PER_SECOND;
const words = Array.from({ length: wordCount }, randomWord);
const shown = capitalize ? words.map(cap) : words;
let passphrase = shown.join(separator);
if (appendDigit) passphrase += randomDigit();
const entropy = entropyBits(wordCount);
const seconds = crackTimeSeconds(entropy, gps);
return { words, passphrase, entropy, crackTime: { seconds, human: formatCrackTime(seconds) } };
}
function cap(w: string): string {
return w.charAt(0).toUpperCase() + w.slice(1);
}
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
Also available in 12 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 →