ExkPasswd.Dictionary (ExkPasswd v0.2.0)

View Source

Dictionary word list management with compile-time optimizations.

This module provides constant-time random word selection through tuple-based storage and pre-transformed case variants.

Optimizations

  1. Tuple-based storage: Words stored as tuples for constant-time indexed access
  2. Pre-transformed cases: Separate uppercase/lowercase/capitalized variants
  3. Pre-computed ranges: Common word length ranges pre-computed at compile time
  4. Custom dictionary support: Runtime :persistent_term storage for user dictionaries

Implementation

  • Word selection: Constant-time tuple indexing
  • Case transformation: Pre-computed variants eliminate runtime transformation
  • Memory cost: ~200KB additional for pre-computed variants

Word List Source

The word list is the EFF Large Wordlist (7,772 of its 7,776 words), developed by the Electronic Frontier Foundation specifically for passphrase generation: https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases

The four hyphenated entries (drop-down, felt-tip, t-shirt, yo-yo) are excluded so every word is strictly lowercase a-z and cannot collide with separator characters. See docs/SECURITY.md for checksums and provenance.

This wordlist provides:

  • High entropy: 7,772 words = ~12.92 bits per word
  • Memorable words: Common, easy-to-remember English words
  • Typability: No complex spellings or obscure words
  • Safety: No offensive or problematic words

Custom Dictionaries

You can load custom dictionaries at runtime for specific use cases:

ExkPasswd.Dictionary.load_custom(:spanish, ["casa", "perro", "gato", ...])
ExkPasswd.Dictionary.random_word_between(4, 8, :none, :spanish)

Custom dictionaries are stored in :persistent_term, so they survive the process that loaded them and reads are zero-copy. Loading (or deleting) a dictionary triggers a global GC scan — load dictionaries once at application start rather than in hot paths. Use delete_custom/1 to remove one.

Examples

iex> ExkPasswd.Dictionary.size()
7772

iex> word = ExkPasswd.Dictionary.random_word_between(4, 8)
...> len = String.length(word)
...> len >= 4 and len <= 8
true

iex> word = ExkPasswd.Dictionary.random_word_between(5, 7, :capitalize)
...> len = String.length(word)
...> len >= 5 and len <= 7
true
iex> String.first(word) == String.upcase(String.first(word))
true

Summary

Functions

Returns all words in the default dictionary.

Returns the count of words between min and max length (inclusive).

Delete a previously loaded custom dictionary.

init() deprecated

No-op kept for backwards compatibility.

Load a custom dictionary for runtime use.

Returns the maximum word length in the default dictionary.

Returns the minimum word length in the default dictionary.

Returns a random word between min and max length with optional case transformation.

Returns the total number of words in the default dictionary.

Functions

all()

@spec all() :: [String.t()]

Returns all words in the default dictionary.

Examples

iex> words = ExkPasswd.Dictionary.all()
...> is_list(words)
true
iex> length(words) > 0
true

count_between(min, max, dict \\ :eff)

@spec count_between(pos_integer(), pos_integer(), atom()) :: non_neg_integer()

Returns the count of words between min and max length (inclusive).

Supports both default :eff dictionary and custom dictionaries.

Unknown custom dictionaries return 0 rather than raising. Callers such as ExkPasswd.Entropy treat that as zero word entropy, which degrades conservatively (entropy is understated, never overstated).

Parameters

  • min - Minimum word length (inclusive)
  • max - Maximum word length (inclusive)
  • dict - Dictionary to use (:eff or custom name, default :eff)

Examples

iex> count = ExkPasswd.Dictionary.count_between(4, 8)
...> is_integer(count) and count > 0
true

delete_custom(name)

@spec delete_custom(atom()) :: :ok

Delete a previously loaded custom dictionary.

Returns :ok whether or not the dictionary existed. Like load_custom/2, this updates :persistent_term and triggers a global GC scan, so prefer loading dictionaries once over repeated load/delete cycles.

Examples

iex> ExkPasswd.Dictionary.load_custom(:temporary, ["uno", "dos", "tres"])
...> ExkPasswd.Dictionary.delete_custom(:temporary)
:ok

init()

This function is deprecated. Custom dictionaries no longer require initialization.
@spec init() :: :ok

No-op kept for backwards compatibility.

Earlier versions stored custom dictionaries in an ETS table that required initialization. Custom dictionaries now live in :persistent_term, which needs no setup, so calling this function is no longer necessary.

load_custom(name, wordlist)

@spec load_custom(atom(), [String.t()]) :: :ok

Load a custom dictionary for runtime use.

The dictionary is stored in :persistent_term and can be referenced by name when generating passwords. Storage is process-independent: the dictionary remains available even after the process that loaded it exits.

Loading a dictionary triggers a global GC scan (a property of :persistent_term updates), so load dictionaries once at application start rather than in hot paths.

Parameters

  • name - Atom identifier for the dictionary
  • wordlist - Non-empty list of non-empty words (strings)

Examples

iex> words = ["casa", "perro", "gato", "libro"]
...> ExkPasswd.Dictionary.load_custom(:spanish, words)
:ok

Empty word lists, empty strings, and non-string entries raise an ArgumentError.

max_length()

@spec max_length() :: pos_integer()

Returns the maximum word length in the default dictionary.

Examples

iex> ExkPasswd.Dictionary.max_length()
9

min_length()

@spec min_length() :: pos_integer()

Returns the minimum word length in the default dictionary.

Examples

iex> ExkPasswd.Dictionary.min_length()
3

random_word_between(min, max, case_transform \\ :none, dict \\ :eff)

@spec random_word_between(pos_integer(), pos_integer(), atom(), atom()) ::
  String.t() | nil

Returns a random word between min and max length with optional case transformation.

Uses tuple-based constant-time lookups for efficient word selection.

Parameters

  • min - Minimum word length (inclusive)
  • max - Maximum word length (inclusive)
  • case_transform - Case transform to apply (:none, :lower, :upper, :capitalize)
  • dict - Dictionary to use (:eff or custom name)

Returns

A random word with the specified length and case, or nil if none exist.

Examples

iex> word = ExkPasswd.Dictionary.random_word_between(4, 8)
...> len = String.length(word)
...> len >= 4 and len <= 8
true

iex> word = ExkPasswd.Dictionary.random_word_between(5, 7, :upper)
...> word == String.upcase(word)
true

random_word_between_with_state(min, max, case_transform \\ :none, dict \\ :eff, random_state)

@spec random_word_between_with_state(
  non_neg_integer(),
  non_neg_integer(),
  atom(),
  atom(),
  ExkPasswd.Buffer.t()
) :: {String.t(), ExkPasswd.Buffer.t()}

Select a random word using a stateful Buffer generator.

This is an optimized version for batch generation that accepts and returns a Buffer state, reducing the number of :crypto.strong_rand_bytes/1 syscalls.

Parameters

  • min - Minimum word length
  • max - Maximum word length
  • case_transform - Case transformation to apply
  • dict - Dictionary name (default: :eff)
  • random_state - A Buffer.t() state

Returns

A tuple of {word, new_random_state}

Examples

iex> alias ExkPasswd.Buffer
...> state = Buffer.new(1_000)
...>
...> {word, _new_state} =
...>   ExkPasswd.Dictionary.random_word_between_with_state(4, 8, :none, :eff, state)
...>
...> len = String.length(word)
...> len >= 4 and len <= 8
true

size()

@spec size() :: pos_integer()

Returns the total number of words in the default dictionary.

Examples

iex> ExkPasswd.Dictionary.size()
7772