ExkPasswd.Dictionary (ExkPasswd v0.3.0)

View Source

Manages the bundled EFF word list and application-supplied dictionaries.

Common EFF length ranges and case variants are compiled into tuples for direct indexed selection. Other ranges are assembled from a small by-length index.

Optimizations

  1. Tuple-based storage: Precomputed ranges support direct 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

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.

Selecting uniformly from all 7,772 entries would provide about 12.92 bits per word. A configured length range usually selects from a smaller subset.

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.

Returns the unique dictionary outputs available for a length range and case variant.

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 unique, valid UTF-8 words

Examples

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

Words are normalized to Unicode NFC. Empty lists, invalid strings, and words that become duplicates after normalization raise ArgumentError. Case variants that produce the same output are stored once so every reachable output remains uniformly selectable.

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 a precomputed tuple for common EFF ranges and a by-length fallback for other ranges.

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() | nil, 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

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

@spec words_between(pos_integer(), pos_integer(), atom(), atom()) :: [String.t()]

Returns the unique dictionary outputs available for a length range and case variant.

This is primarily useful for auditing a configured output space. Unknown custom dictionaries return an empty list.

Parameters

  • min - Minimum word length, inclusive
  • max - Maximum word length, inclusive
  • case_transform - One of :none, :lower, :upper, or :capitalize
  • dict - :eff or the name of a loaded custom dictionary

Examples

iex> words = ExkPasswd.Dictionary.words_between(4, 4)
...> length(words) > 0 and Enum.all?(words, &(String.length(&1) == 4))
true