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;
}