import { entropyBits } from './entropy'; import { randomInt, randomItem } from './random'; export const PASSPHRASE_MIN_WORDS = 1; export const PASSPHRASE_MAX_WORDS = 20; /** A fixed separator, or 'digit' for a fresh random digit in every gap. */ export type Separator = '-' | ' ' | '.' | '_' | '' | 'digit'; export type PassphraseOptions = { words: number; separator: Separator; /** Upper-case the first letter of every word: a fixed rule, so it adds no entropy. */ capitalize: boolean; /** Append one random digit to one random word. */ addNumber: boolean; }; export class PassphraseOptionsError extends Error { override name = 'PassphraseOptionsError'; } // Lists already checked for duplicates (the check is O(n); a phrase should cost O(words)). const checkedLists = new WeakSet(); function validate(wordlist: readonly string[], count: number) { if (!Number.isInteger(count) || count < PASSPHRASE_MIN_WORDS || count > PASSPHRASE_MAX_WORDS) { throw new PassphraseOptionsError( `Word count must be a whole number from ${PASSPHRASE_MIN_WORDS} to ${PASSPHRASE_MAX_WORDS}.`, ); } // Duplicates would make some words likelier and the entropy figure wrong. if (checkedLists.has(wordlist)) return; if (wordlist.length < 2 || new Set(wordlist).size !== wordlist.length) { throw new PassphraseOptionsError('The wordlist must contain at least two unique words.'); } checkedLists.add(wordlist); } /** Picks `count` words independently and uniformly (repeats allowed, as in diceware). */ export function generatePassphraseWords(wordlist: readonly string[], count: number): string[] { validate(wordlist, count); return Array.from({ length: count }, () => randomItem(wordlist)); } export function generatePassphrase( wordlist: readonly string[], options: PassphraseOptions, ): string { const words = generatePassphraseWords(wordlist, options.words).map((word) => options.capitalize ? word[0].toUpperCase() + word.slice(1) : word, ); if (options.addNumber) words[randomInt(words.length)] += String(randomInt(10)); if (options.separator !== 'digit') return words.join(options.separator); return words.reduce((phrase, word) => `${phrase}${randomInt(10)}${word}`); } /** * Exact entropy of generatePassphrase's output: log2(listSize) per word, log2(10) per * random-digit gap, log2(10 × words) for the added number. Exact when the list is * prefix-free (the EFF large list is), so every output decodes to one choice of words. */ export function passphraseEntropyBits(listSize: number, options: PassphraseOptions): number { if (!Number.isInteger(listSize) || listSize < 2) { throw new PassphraseOptionsError('The wordlist must contain at least two unique words.'); } validate(['a', 'b'], options.words); const gaps = options.separator === 'digit' ? options.words - 1 : 0; const number = options.addNumber ? Math.log2(10 * options.words) : 0; return entropyBits(listSize, options.words) + gaps * Math.log2(10) + number; } // "From my words": the user's own words mixed with random EFF words. The user's words are // treated as KNOWN to an attacker (they are often names, places or hobbies), so they add no // entropy - only the random words, the random order and the random digits count. export const MY_WORDS_MAX = 5; export const MY_WORD_MAX_LENGTH = 24; export const MY_RANDOM_WORDS = { min: 1, max: 8, default: 5 } as const; export type MyWordsOptions = { myWords: string[]; /** Random words from the list, added to the user's words. */ randomWords: number; /** Put all words in a random order (otherwise: your words first, as typed). */ shuffle: boolean; separator: Separator; addNumber: boolean; }; /** Splits typed text into words: spaces or commas between them, empty parts dropped. */ export function parseMyWords(text: string): string[] { return text .split(/[\s,]+/) .map((word) => word.trim()) .filter(Boolean); } /** Why these words cannot be used, or null. */ export function myWordsError(words: string[]): string | null { if (words.length === 0) return 'Type at least one word of your own.'; if (words.length > MY_WORDS_MAX) return `Use at most ${MY_WORDS_MAX} of your own words.`; if (words.some((word) => [...word].length > MY_WORD_MAX_LENGTH)) { return `Each word can have at most ${MY_WORD_MAX_LENGTH} characters.`; } return null; } /** Entropy for the "your words" input, or null while that input is empty or invalid. */ export function validMyWordsEntropyBits( wordlist: readonly string[] | null, options: MyWordsOptions, ): MyWordsEntropy | null { if (!wordlist || myWordsError(options.myWords)) return null; return myWordsEntropyBits(wordlist, options); } /** * Whether the random order can be counted: only when the user's words are distinct, letters * only, and not in the wordlist - otherwise two different shuffles could give the same phrase * and counting them would overstate the entropy. */ export function orderCounts(myWords: string[], wordlist: readonly string[]): boolean { const lower = myWords.map((word) => word.toLowerCase()); const listed = new Set(wordlist); return ( new Set(lower).size === lower.length && lower.every((word) => /^\p{L}+$/u.test(word) && !listed.has(word)) ); } // Without a separator, your words could run into the random ones ("cat" + "alog" = "catalog"), // so different choices could give the same phrase. Every other separator keeps words apart. function checkMyWordsSeparator(separator: Separator) { if (separator === '') { throw new PassphraseOptionsError('Words from your own list need a separator between them.'); } } function shuffleInPlace(items: T[]): T[] { for (let i = items.length - 1; i > 0; i -= 1) { const j = randomInt(i + 1); [items[i], items[j]] = [items[j], items[i]]; } return items; } export function generateFromMyWords(wordlist: readonly string[], options: MyWordsOptions): string { const problem = myWordsError(options.myWords); if (problem) throw new PassphraseOptionsError(problem); checkMyWordsSeparator(options.separator); validate(wordlist, options.randomWords); const words = [...options.myWords, ...generatePassphraseWords(wordlist, options.randomWords)]; if (options.shuffle) shuffleInPlace(words); if (options.addNumber) words[randomInt(words.length)] += String(randomInt(10)); if (options.separator !== 'digit') return words.join(options.separator); return words.reduce((phrase, word) => `${phrase}${randomInt(10)}${word}`); } /** log2(n!) */ const log2Factorial = (n: number) => { let bits = 0; for (let i = 2; i <= n; i += 1) bits += Math.log2(i); return bits; }; export type MyWordsEntropy = { total: number; randomWords: number; order: number; digits: number; /** False when the order was not counted (see orderCounts). */ orderCounted: boolean; }; /** * Entropy of generateFromMyWords for an attacker who knows your words, the list and the * settings. Order: the positions and order of your k words among k + n words, * log2((n + k)! / n!) - the random words' own order is already in their per-word bits. */ export function myWordsEntropyBits( wordlist: readonly string[], options: MyWordsOptions, ): MyWordsEntropy { checkMyWordsSeparator(options.separator); validate(wordlist, options.randomWords); const k = options.myWords.length; const n = options.randomWords; const randomWords = entropyBits(wordlist.length, n); const orderCounted = options.shuffle && orderCounts(options.myWords, wordlist); const order = orderCounted ? log2Factorial(n + k) - log2Factorial(n) : 0; const gaps = options.separator === 'digit' ? (n + k - 1) * Math.log2(10) : 0; // Where the digit goes is countable only when the words are unambiguous (same rule as order). const number = options.addNumber ? Math.log2(10) + (orderCounts(options.myWords, wordlist) ? Math.log2(n + k) : 0) : 0; return { total: randomWords + order + gaps + number, randomWords, order, digits: gaps + number, orderCounted, }; }