diff --git a/components/password-input.tsx b/components/password-input.tsx index 322c7df..df98239 100644 --- a/components/password-input.tsx +++ b/components/password-input.tsx @@ -13,17 +13,23 @@ interface PasswordInputProps { export function PasswordInput({ password, onChange, mode, onSubmit }: PasswordInputProps) { const [visible, setVisible] = useState(false); const [generating, setGenerating] = useState(false); + const [capitalize, setCapitalize] = useState(false); + const [includeNumber, setIncludeNumber] = useState(false); const handleGenerate = useCallback(async () => { setGenerating(true); try { - const passphrase = await generatePassphrase(); + const passphrase = await generatePassphrase({ + wordCount: 6, + capitalize, + includeNumber, + }); onChange(passphrase); setVisible(true); } finally { setGenerating(false); } - }, [onChange]); + }, [onChange, capitalize, includeNumber]); return (
@@ -63,6 +69,30 @@ export function PasswordInput({ password, onChange, mode, onSubmit }: PasswordIn )}
+ + {mode === 'encrypt' && ( +
+ + +
+ )} +

{mode === 'encrypt' ? 'Choose a strong password or generate a passphrase' diff --git a/lib/passphrase.test.ts b/lib/passphrase.test.ts index 9e7b14a..685014a 100644 --- a/lib/passphrase.test.ts +++ b/lib/passphrase.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi } from 'vitest'; import { generatePassphrase, passphraseEntropy } from './passphrase'; import { EFF_LONG_WORDLIST } from './eff-wordlist'; +// --------------------------------------------------------------------------- +// Wordlist sanity checks +// --------------------------------------------------------------------------- + describe('EFF wordlist', () => { it('contains exactly 7776 words (6^5)', () => { expect(EFF_LONG_WORDLIST).toHaveLength(7776); @@ -25,7 +29,11 @@ describe('EFF wordlist', () => { }); }); -describe('generatePassphrase', () => { +// --------------------------------------------------------------------------- +// Legacy API (positional arguments) +// --------------------------------------------------------------------------- + +describe('generatePassphrase (legacy positional API)', () => { it('returns 6 words separated by hyphens by default', async () => { const passphrase = await generatePassphrase(); const words = passphrase.split('-'); @@ -75,6 +83,227 @@ describe('generatePassphrase', () => { }); }); +// --------------------------------------------------------------------------- +// Options object API — basic +// --------------------------------------------------------------------------- + +describe('generatePassphrase (options API)', () => { + it('accepts an options object with defaults', async () => { + const passphrase = await generatePassphrase({}); + const words = passphrase.split('-'); + expect(words).toHaveLength(6); + }); + + it('respects wordCount in options', async () => { + const passphrase = await generatePassphrase({ wordCount: 3 }); + // Without capitalize or numbers, all parts should be lowercase words + const words = passphrase.split('-'); + expect(words).toHaveLength(3); + }); + + it('respects separator in options', async () => { + const passphrase = await generatePassphrase({ wordCount: 4, separator: '_' }); + expect(passphrase.split('_')).toHaveLength(4); + }); +}); + +// --------------------------------------------------------------------------- +// Capitalize option +// --------------------------------------------------------------------------- + +describe('generatePassphrase capitalize option', () => { + it('produces some capitalized words when enabled', async () => { + // Generate many passphrases; statistically at least one word should be capitalized + let sawCapitalized = false; + let sawLowercase = false; + for (let i = 0; i < 30; i++) { + const passphrase = await generatePassphrase({ wordCount: 6, capitalize: true }); + const words = passphrase.split('-'); + for (const w of words) { + if (w[0] >= 'A' && w[0] <= 'Z') sawCapitalized = true; + if (w[0] >= 'a' && w[0] <= 'z') sawLowercase = true; + } + if (sawCapitalized && sawLowercase) break; + } + expect(sawCapitalized).toBe(true); + expect(sawLowercase).toBe(true); + }); + + it('capitalized words still come from the wordlist', async () => { + const wordSet = new Set(EFF_LONG_WORDLIST); + for (let i = 0; i < 10; i++) { + const passphrase = await generatePassphrase({ wordCount: 6, capitalize: true }); + const words = passphrase.split('-'); + for (const w of words) { + expect(wordSet.has(w.toLowerCase())).toBe(true); + } + } + }); + + it('does not capitalize when option is false', async () => { + for (let i = 0; i < 10; i++) { + const passphrase = await generatePassphrase({ wordCount: 6, capitalize: false }); + expect(passphrase).toMatch(/^[a-z-]+$/); + } + }); + + it('capitalization only affects the first letter', async () => { + for (let i = 0; i < 20; i++) { + const passphrase = await generatePassphrase({ wordCount: 4, capitalize: true }); + const words = passphrase.split('-'); + for (const w of words) { + // After the first char, the rest should be lowercase + if (w.length > 1) { + expect(w.slice(1)).toMatch(/^[a-z-]*$/); + } + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Include number option +// --------------------------------------------------------------------------- + +describe('generatePassphrase includeNumber option', () => { + it('inserts digits between words when enabled', async () => { + const passphrase = await generatePassphrase({ + wordCount: 4, + includeNumber: true, + separator: '-', + }); + + // Pattern: word-digit-word-digit-word-digit-word + const parts = passphrase.split('-'); + // 4 words + 3 digits = 7 parts + expect(parts).toHaveLength(7); + + // Odd-indexed parts (1, 3, 5) should be single digits + expect(parts[1]).toMatch(/^[0-9]$/); + expect(parts[3]).toMatch(/^[0-9]$/); + expect(parts[5]).toMatch(/^[0-9]$/); + }); + + it('does not insert numbers for a single word', async () => { + const passphrase = await generatePassphrase({ wordCount: 1, includeNumber: true }); + // Should just be a word, no digit + expect(passphrase).toMatch(/^[a-z]+(-[a-z]+)*$/); + }); + + it('digits are in 0-9 range', async () => { + const digits = new Set(); + for (let i = 0; i < 50; i++) { + const passphrase = await generatePassphrase({ + wordCount: 3, + includeNumber: true, + separator: '-', + }); + const parts = passphrase.split('-'); + // parts[1] and parts[3] are digits + digits.add(parts[1]); + digits.add(parts[3]); + } + // All collected values should be single digits + for (const d of digits) { + expect(d).toMatch(/^[0-9]$/); + } + // With 50 iterations we should see more than just one digit + expect(digits.size).toBeGreaterThan(1); + }); + + it('does not include digits when option is false', async () => { + for (let i = 0; i < 10; i++) { + const passphrase = await generatePassphrase({ wordCount: 4, includeNumber: false }); + expect(passphrase).not.toMatch(/[0-9]/); + } + }); + + it('number of digits equals wordCount - 1', async () => { + for (const wc of [2, 3, 5, 8]) { + const passphrase = await generatePassphrase({ + wordCount: wc, + includeNumber: true, + separator: '-', + }); + const parts = passphrase.split('-'); + // wc words + (wc - 1) digits + expect(parts).toHaveLength(wc + (wc - 1)); + } + }); +}); + +// --------------------------------------------------------------------------- +// Combined capitalize + includeNumber +// --------------------------------------------------------------------------- + +describe('generatePassphrase capitalize + includeNumber combined', () => { + it('produces passphrases with both features', async () => { + let sawCapitalized = false; + let sawDigit = false; + + for (let i = 0; i < 30; i++) { + const passphrase = await generatePassphrase({ + wordCount: 4, + capitalize: true, + includeNumber: true, + separator: '-', + }); + + const parts = passphrase.split('-'); + // Should have 4 words + 3 digits = 7 parts + expect(parts).toHaveLength(7); + + for (let j = 0; j < parts.length; j++) { + if (j % 2 === 0) { + // Word position — check for capitalization + if (parts[j][0] >= 'A' && parts[j][0] <= 'Z') sawCapitalized = true; + } else { + // Digit position + expect(parts[j]).toMatch(/^[0-9]$/); + sawDigit = true; + } + } + if (sawCapitalized && sawDigit) break; + } + + expect(sawCapitalized).toBe(true); + expect(sawDigit).toBe(true); + }); + + it('words are still from the EFF wordlist', async () => { + const wordSet = new Set(EFF_LONG_WORDLIST); + for (let i = 0; i < 10; i++) { + const passphrase = await generatePassphrase({ + wordCount: 4, + capitalize: true, + includeNumber: true, + separator: '-', + }); + const parts = passphrase.split('-'); + for (let j = 0; j < parts.length; j += 2) { + expect(wordSet.has(parts[j].toLowerCase())).toBe(true); + } + } + }); + + it('works with custom separator', async () => { + const passphrase = await generatePassphrase({ + wordCount: 3, + capitalize: true, + includeNumber: true, + separator: '.', + }); + const parts = passphrase.split('.'); + expect(parts).toHaveLength(5); // 3 words + 2 digits + expect(parts[1]).toMatch(/^[0-9]$/); + expect(parts[3]).toMatch(/^[0-9]$/); + }); +}); + +// --------------------------------------------------------------------------- +// Randomness +// --------------------------------------------------------------------------- + describe('generatePassphrase randomness', () => { it('generates different passphrases on consecutive calls', async () => { const results = new Set(); @@ -92,8 +321,35 @@ describe('generatePassphrase randomness', () => { } expect(firstWords.size).toBeGreaterThan(1); }); + + it('capitalize produces varied results across calls', async () => { + const results = new Set(); + for (let i = 0; i < 20; i++) { + results.add(await generatePassphrase({ wordCount: 6, capitalize: true })); + } + expect(results.size).toBe(20); + }); + + it('includeNumber produces varied digits', async () => { + const digits = new Set(); + for (let i = 0; i < 50; i++) { + const passphrase = await generatePassphrase({ + wordCount: 2, + includeNumber: true, + separator: '-', + }); + const parts = passphrase.split('-'); + digits.add(parts[1]); // the digit between the two words + } + // Should see multiple different digits + expect(digits.size).toBeGreaterThan(3); + }); }); +// --------------------------------------------------------------------------- +// Rejection sampling +// --------------------------------------------------------------------------- + describe('generatePassphrase rejection sampling', () => { it('rejects biased values and retries', async () => { const original = crypto.getRandomValues.bind(crypto); @@ -121,7 +377,11 @@ describe('generatePassphrase rejection sampling', () => { }); }); -describe('passphraseEntropy', () => { +// --------------------------------------------------------------------------- +// Entropy calculation — legacy (number argument) +// --------------------------------------------------------------------------- + +describe('passphraseEntropy (legacy number argument)', () => { it('returns ~12.9 bits per word (log2(7776))', () => { const perWord = passphraseEntropy(1); expect(perWord).toBeCloseTo(Math.log2(7776), 5); @@ -144,3 +404,54 @@ describe('passphraseEntropy', () => { expect(passphraseEntropy(0)).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// Entropy calculation — options object +// --------------------------------------------------------------------------- + +describe('passphraseEntropy (options API)', () => { + const basePerWord = Math.log2(7776); + + it('matches legacy result when no enhancements', () => { + const legacy = passphraseEntropy(6); + const opts = passphraseEntropy({ wordCount: 6 }); + expect(opts).toBeCloseTo(legacy, 10); + }); + + it('adds 1 bit per word when capitalize is true', () => { + const base = passphraseEntropy({ wordCount: 6, capitalize: false }); + const withCap = passphraseEntropy({ wordCount: 6, capitalize: true }); + expect(withCap - base).toBeCloseTo(6, 10); // 6 words * 1 bit + }); + + it('adds ~3.32 bits per digit when includeNumber is true', () => { + const base = passphraseEntropy({ wordCount: 4, includeNumber: false }); + const withNum = passphraseEntropy({ wordCount: 4, includeNumber: true }); + // 4 words → 3 digits → 3 * log2(10) + expect(withNum - base).toBeCloseTo(3 * Math.log2(10), 5); + }); + + it('does not add number entropy for single word', () => { + const base = passphraseEntropy({ wordCount: 1, includeNumber: false }); + const withNum = passphraseEntropy({ wordCount: 1, includeNumber: true }); + expect(withNum).toBeCloseTo(base, 10); + }); + + it('combines capitalize and number entropy', () => { + const both = passphraseEntropy({ wordCount: 6, capitalize: true, includeNumber: true }); + const expected = + 6 * basePerWord + // base words + 6 * 1 + // capitalize + 5 * Math.log2(10); // numbers (5 digits between 6 words) + expect(both).toBeCloseTo(expected, 5); + }); + + it('defaults to 6 words with no enhancements', () => { + const defaultEntropy = passphraseEntropy({}); + expect(defaultEntropy).toBeCloseTo(6 * basePerWord, 10); + }); + + it('returns 0 for 0 words regardless of options', () => { + expect(passphraseEntropy({ wordCount: 0, capitalize: true, includeNumber: true })).toBe(0); + }); +}); diff --git a/lib/passphrase.ts b/lib/passphrase.ts index ab574c0..df84779 100644 --- a/lib/passphrase.ts +++ b/lib/passphrase.ts @@ -3,6 +3,10 @@ * Each word provides ~12.9 bits of entropy (log2(7776)). * Default 6 words ≈ 77.5 bits — comparable to a random 12-char mixed-case+symbol password. * + * Options: + * - `capitalize`: randomly capitalize each word (adds ~1 bit per word) + * - `includeNumber`: insert a random digit (0-9) between each pair of words (adds ~3.32 bits each) + * * @see https://www.eff.org/dice */ @@ -16,20 +20,81 @@ async function loadWordlist(): Promise { return EFF_LONG_WORDLIST; } +/** Options for passphrase generation. */ +export interface PassphraseOptions { + /** Number of words (default 6). */ + wordCount?: number; + /** Separator between words/numbers (default "-"). */ + separator?: string; + /** Randomly capitalize each word with 50 % probability (default false). */ + capitalize?: boolean; + /** Insert a random digit (0-9) between each pair of words (default false). */ + includeNumber?: boolean; +} + +/** + * Get a single cryptographically random value in [0, max) using rejection + * sampling to avoid modulo bias. Uses Uint16Array (range 0-65535). + */ +function secureRandomBelow(max: number): number { + const limit = 65536 - (65536 % max); + while (true) { + const buf = new Uint16Array(1); + crypto.getRandomValues(buf); + if (buf[0] < limit) return buf[0] % max; + } +} + +/** + * Capitalize the first letter of a string. + */ +function capitalizeWord(word: string): string { + if (word.length === 0) return word; + return word[0].toUpperCase() + word.slice(1); +} + /** * Generate a cryptographically random passphrase. * - * @param wordCount Number of words (default 6 ≈ 77.5 bits entropy) - * @param separator Separator between words (default "-") - * @returns A passphrase string like "correct-horse-battery-staple-foo-bar" + * @param optionsOrWordCount - Either a PassphraseOptions object or a word count + * number for backward compatibility. + * @param separatorCompat - Separator string (only used when first arg is a number). + * @returns A passphrase string. + * + * @example + * // Legacy call style (still works): + * await generatePassphrase(6, '-'); + * + * @example + * // New options style: + * await generatePassphrase({ wordCount: 6, capitalize: true, includeNumber: true }); */ export async function generatePassphrase( - wordCount = 6, - separator = '-', + optionsOrWordCount: PassphraseOptions | number = 6, + separatorCompat = '-', ): Promise { + // Normalize arguments for backward compatibility + const opts: Required = + typeof optionsOrWordCount === 'number' + ? { + wordCount: optionsOrWordCount, + separator: separatorCompat, + capitalize: false, + includeNumber: false, + } + : { + wordCount: optionsOrWordCount.wordCount ?? 6, + separator: optionsOrWordCount.separator ?? '-', + capitalize: optionsOrWordCount.capitalize ?? false, + includeNumber: optionsOrWordCount.includeNumber ?? false, + }; + + const { wordCount, separator, capitalize, includeNumber } = opts; + const wordlist = await loadWordlist(); const len = wordlist.length; + // Pick random words using bulk randomness + rejection sampling const buf = new Uint16Array(wordCount); crypto.getRandomValues(buf); @@ -42,15 +107,64 @@ export async function generatePassphrase( crypto.getRandomValues(extra); val = extra[0]; } - words.push(wordlist[val % len]); + let word = wordlist[val % len]; + + // Capitalize: each word independently has a 50 % chance + if (capitalize) { + const flip = secureRandomBelow(2); // 0 or 1 + if (flip === 1) { + word = capitalizeWord(word); + } + } + + words.push(word); + } + + // If includeNumber, insert a random digit between each pair of words. + // Result: word0 word1 word2 ... + if (includeNumber && words.length > 1) { + const parts: string[] = [words[0]]; + for (let i = 1; i < words.length; i++) { + parts.push(String(secureRandomBelow(10))); + parts.push(words[i]); + } + return parts.join(separator); } return words.join(separator); } /** - * Approximate entropy in bits for a passphrase of the given word count. + * Approximate entropy in bits for a passphrase with the given options. + * + * - Base: wordCount * log2(7776) + * - Capitalize adds 1 bit per word (uppercase or not) + * - Numbers add log2(10) ≈ 3.32 bits per digit (one between each word pair) */ -export function passphraseEntropy(wordCount: number): number { - return wordCount * Math.log2(7776); +export function passphraseEntropy( + wordCountOrOptions: number | PassphraseOptions = 6, +): number { + const opts: Required = + typeof wordCountOrOptions === 'number' + ? { wordCount: wordCountOrOptions, separator: '-', capitalize: false, includeNumber: false } + : { + wordCount: wordCountOrOptions.wordCount ?? 6, + separator: wordCountOrOptions.separator ?? '-', + capitalize: wordCountOrOptions.capitalize ?? false, + includeNumber: wordCountOrOptions.includeNumber ?? false, + }; + + const { wordCount, capitalize, includeNumber } = opts; + + let entropy = wordCount * Math.log2(7776); + + if (capitalize) { + entropy += wordCount * 1; // 1 bit per word (cap or not) + } + + if (includeNumber && wordCount > 1) { + entropy += (wordCount - 1) * Math.log2(10); // ~3.32 bits per digit + } + + return entropy; }