ExkPasswd

ExkPasswd generates strong passwords by combining cryptographically random words with optional digits, symbols, and transformations. The result is easier to remember than a random character string without hiding how its security is measured.

Test SuitecodecovHex.pmDocumentationLicenseElixir


Try It Interactively

The Livebook notebooks are executable guides, not extra API reference pages. Start with the quick-start notebook in a browser:

Run in Livebook


History & Inspiration

The word-based password idea was popularized by Randall Munroe's XKCD comic #936. It shows why a sequence of independently chosen words can be both easier to remember and harder to guess than a short password built from predictable character tricks.

XKCD #936, Password Strength
XKCD #936, “Password Strength,” by Randall Munroe.

The comic inspired Bart Busschots to create Crypt::HSXKPasswd, a configurable Perl implementation of the idea. The concept was later ported to JavaScript and then to Elixir by Michael Westbay.

ExkPasswd continues that lineage with the EFF Large Wordlist, unbiased cryptographic sampling, strict configuration validation, custom dictionaries, extensible transforms, batch generation, and explicit entropy analysis.


Why Word-Based Passwords?

A short random string such as x4$9Kp2m can be difficult to remember and type. A human-created variation is often worse because substitutions and punctuation tend to follow familiar patterns.

A generated password such as aviator-DANDER-REACTOR-GRATING-STARFISH has different advantages:

Words are not magic. A quotation, lyric, idiom, or phrase chosen by a person is not equivalent to independently sampled words. ExkPasswd's security comes from the random process and the number of reachable outputs, not from obscurity or visual complexity.


Features

Core Features

Advanced Features


Installation

Add exk_passwd to the dependencies in mix.exs:

def deps do
[
{:exk_passwd, "~> 0.3.0"}
]
end

Then fetch the dependency:

mix deps.get

Quick Start

Basic Usage

Every output below is random; the comments show real examples rather than fixed return values.

# Generate with the default preset
ExkPasswd.generate()
#=> "..83=cocoa=ABDOMEN=tracing=22.."
# Use a preset by atom
ExkPasswd.generate(:xkcd)
#=> "aviator-DANDER-REACTOR-GRATING-STARFISH"
# Preset names can also be strings
ExkPasswd.generate("wifi") |> String.length()
#=> 63
# Start from a preset and override selected options
ExkPasswd.generate(:xkcd, num_words: 6)
#=> "mobile-handful-EATABLE-EXIT-NUTRIENT-PUPPET"

Custom Configuration

Pass a keyword list directly:

ExkPasswd.generate(
num_words: 4,
word_length: 5..7,
case_transform: :capitalize,
separator: "-",
digits: {2, 2},
padding: %{char: "!", before: 1, after: 1}
)
#=> "!86-Arrive-Amnesty-Bauble-Kosher-51!"

Or construct and reuse a validated configuration:

config =
ExkPasswd.Config.new!(
num_words: 4,
word_length: 5..7,
case_transform: :capitalize,
separator: "-",
digits: {2, 2},
padding: %{char: "!", before: 1, after: 1}
)
password = ExkPasswd.generate(config)
report = ExkPasswd.Entropy.calculate(password, config)

Use ExkPasswd.Config.new/1 when invalid user input should return an error tuple instead of raising.


Available Presets

The entropy figures are approximate seen min-entropy for the current EFF dictionary and preset definitions. They assume an attacker knows the library, dictionary, and full configuration.

:default

Three words with alternating case, two digits on each side, random separators, and symbol padding. Approximate seen entropy: 59.4 bits.

ExkPasswd.generate(:default)
#=> "..83=cocoa=ABDOMEN=tracing=22.."

:xkcd

Five words separated by hyphens with independently randomized word casing. Approximate seen entropy: 67.9 bits.

ExkPasswd.generate(:xkcd)
#=> "aviator-DANDER-REACTOR-GRATING-STARFISH"

:web32

Four short words plus digits and symbols, constructed to remain at or below 32 characters. Approximate seen entropy: 65.0 bits.

password = ExkPasswd.generate(:web32)
String.length(password) <= 32
#=> true

:web16

A compatibility fallback for systems that impose a 16-character maximum. Approximate seen entropy: 37.0 bits, so it is not a general recommendation.

password = ExkPasswd.generate(:web16)
String.length(password) <= 16
#=> true

:wifi

Generates exactly 63 printable ASCII characters for a WPA/WPA2-Personal passphrase. Approximate seen entropy: 105.2 bits.

password = ExkPasswd.generate(:wifi)
String.length(password)
#=> 63

WPA/WPA2-Personal accepts an 8–63 character ASCII passphrase. A 64-character hexadecimal value represents a raw PSK rather than a longer passphrase. See the wpa-psk(8) manual.

:apple_id

Generates upper- and lowercase letters, numbers, and punctuation available on standard Apple keyboards. Approximate seen entropy: 54.7 bits.

ExkPasswd.generate(:apple_id)
#=> "?32.unmixed.SPEECH.sway.73?"

The preset guarantees the documented composition properties, but Apple may still reject common or otherwise policy-blocked passwords. See Apple's account security guidance.

:security

Generates a random fake answer for a legacy security-question field. Approximate seen entropy: 77.1 bits.

ExkPasswd.generate(:security)
#=> "blip italics pawing unworthy name recliner!"

Knowledge-based authentication itself is not recommended by current NIST guidance. When a service still requires an answer, store the generated value as you would any other password.


Configuration Options

All generation settings live in ExkPasswd.Config:

ExkPasswd.Config.new!(
num_words: 3, # 1..10
word_length: 4..8, # ascending range, maximum 50
word_length_bounds: nil, # custom bounds for non-Latin dictionaries
case_transform: :alternate, # see the modes below
separator: "-", # one symbol or a set of possible symbols
digits: {2, 2}, # before and after, each 0..5
padding: %{
char: "!@#",
before: 1,
after: 1,
to_length: 0 # 0 disables minimum-length padding
},
substitutions: %{"a" => "@", "e" => "3"},
substitution_mode: :none, # :none, :always, or :random
dictionary: :eff,
meta: %{transforms: []},
validators: []
)

Separators and padding characters accept punctuation and symbols, not letters or digits. A string containing several graphemes is treated as a set of possible characters.

padding.to_length is a minimum length, never a truncation rule. If the natural password is already longer, ExkPasswd returns it intact rather than discarding random words or digits.

Case Transformations

Character Substitutions

config =
ExkPasswd.Config.new!(
substitutions: %{"a" => "@", "e" => "3", "o" => "0"},
substitution_mode: :random
)
ExkPasswd.generate(config)
#=> "__15-clone-BONSAI-m0l3cul3-22__"

Random substitution receives entropy credit only when original and substituted outputs are distinct. Deterministic substitution can reduce the output space when multiple source words collapse to the same result; the entropy model accounts for those collisions.


Security

Cryptographic Randomness

All password choices ultimately use :crypto.strong_rand_bytes/1. Integer ranges, including buffered batch generation, use rejection sampling so non-power-of-two ranges remain uniform.

ExkPasswd never uses :rand, Enum.random/1, timestamps, process identifiers, or a caller-provided seed for password material. This does not protect a system whose operating system, runtime, or hardware random source is compromised.

Dictionary & Security Model

The bundled dictionary contains 7,772 lowercase ASCII words derived from the EFF Large Wordlist. Four hyphenated entries are omitted so - can serve as an unambiguous separator. Selecting from the full bundled list would provide about 12.92 bits per word, but length filters and transforms can change the effective pool.

The security assumptions are deliberately public: an attacker may know the library, dictionary, preset, and every configuration option. Only the random choices are secret.

For provenance, checksums, and the exact threat model, see docs/SECURITY.md.

Understanding Entropy Reports

ExkPasswd reports two different quantities:

The weak, fair, good, and excellent ratings are project-defined presentation bands, not NIST or OWASP standards. Crack-time strings use a simple one-billion-guesses-per-second comparison model; actual rates depend on online throttling, MFA, password hashing, hardware, and breach conditions.

What ExkPasswd Does Not Solve

This library generates passwords. It does not store or hash them, check breach blocklists, prevent phishing or keylogging, provide rate limiting or MFA, or detect whether a destination silently normalizes or truncates input.

Use a password manager when possible, enable MFA for important accounts, and test the exact destination field before relying on whitespace, Unicode, or uncommon punctuation.

Current verifier guidance emphasizes length, blocklists, rate limiting, and support for long passphrases:


API Reference

The complete API documentation lives on HexDocs. These are the main entry points.

Password Generation

ExkPasswd.generate/0

Generates one password with the :default preset:

password = ExkPasswd.generate()
is_binary(password)
#=> true

ExkPasswd.generate/1

Accepts a preset atom, preset string, keyword options, or a validated config:

ExkPasswd.generate(:xkcd)
ExkPasswd.generate("xkcd")
ExkPasswd.generate(num_words: 4, separator: "-")
config = ExkPasswd.Config.new!(num_words: 4, separator: "-")
ExkPasswd.generate(config)

ExkPasswd.generate/2

Starts with a built-in or runtime preset and applies validated overrides:

ExkPasswd.generate(:xkcd, num_words: 6, case_transform: :lower)

Configuration

new/1 returns a tagged tuple for expected input errors:

{:ok, config} = ExkPasswd.Config.new(num_words: 4)
{:error, reason} = ExkPasswd.Config.new(num_words: 0)
String.contains?(reason, "num_words")
#=> true

new!/1 returns the config or raises ArgumentError:

config = ExkPasswd.Config.new!(num_words: 4)

Constructors reject unknown and duplicate options. Public generation functions also validate %ExkPasswd.Config{} values assembled directly by callers.

Presets

Built-in presets work without starting a process:

ExkPasswd.Config.Presets.get(:xkcd)
ExkPasswd.Config.Presets.get("wifi")
ExkPasswd.Config.Presets.list()

Runtime registration requires the preset registry in your supervision tree:

children = [
{ExkPasswd.Config.Presets, []}
]

You can register a complete config or compose from an existing preset:

ExkPasswd.Config.Presets.register(
:six_words,
:xkcd,
num_words: 6,
case_transform: :lower
)
ExkPasswd.generate(:six_words)

Batch Generation

config = ExkPasswd.Config.Presets.get(:default)
# Buffered generation
ExkPasswd.Batch.generate_batch(100, config)
# Enforce uniqueness with a bounded retry budget
ExkPasswd.Batch.generate_unique_batch(
100,
config,
max_attempts: 10_000
)
# Split a large batch across worker processes
ExkPasswd.Batch.generate_parallel(10_000, config, workers: 4)

Buffering is not guaranteed to be faster for every workload. Batch size, runtime version, and hardware all matter, so use the included benchmarks on the target system. Unique generation raises if it cannot produce the requested number of distinct values within max_attempts.

Entropy & Strength

config = ExkPasswd.Config.Presets.get(:xkcd)
password = ExkPasswd.generate(config)
entropy = ExkPasswd.Entropy.calculate(password, config)
Float.round(entropy.seen, 1)
#=> 67.9
strength = ExkPasswd.Strength.analyze(password, config)
Map.keys(strength) |> Enum.sort()
#=> [:entropy_bits, :rating, :score]
ExkPasswd.Strength.rating(password, config)
#=> :good

Top-level delegates are also available as ExkPasswd.calculate_entropy/2, ExkPasswd.analyze_strength/2, and ExkPasswd.strength_rating/2.

Custom Dictionaries

words = ["casa", "perro", "gato", "libro", "nube"]
ExkPasswd.Dictionary.load_custom(:spanish, words)
config =
ExkPasswd.Config.new!(
dictionary: :spanish,
word_length: 4..5,
num_words: 4,
case_transform: :lower,
separator: "-",
digits: {0, 0},
padding: %{char: "", before: 0, after: 0, to_length: 0}
)
ExkPasswd.generate(config)
#=> "gato-casa-nube-libro"

Custom words must be non-empty valid UTF-8 strings. They are normalized to NFC, and duplicates after normalization are rejected. Case-output duplicates are stored once so selection remains uniform over reachable outputs.

Custom dictionaries live in :persistent_term. Load them during application startup rather than in a request path because writes trigger a global garbage-collection scan. The application remains responsible for dictionary quality, size, memorability, and content.

Transform Protocol

Simple substitutions belong directly in the config. More involved transformations implement ExkPasswd.Transform and go in config.meta.transforms.

The bundled Romaji transform can make a Kana dictionary typeable on an ASCII keyboard:

ExkPasswd.Dictionary.load_custom(
:japanese,
["さくら", "やま", "うみ", "そら"]
)
config =
ExkPasswd.Config.new!(
dictionary: :japanese,
word_length: 2..6,
word_length_bounds: 1..10,
num_words: 3,
case_transform: :none,
separator: "-",
digits: {0, 0},
padding: %{char: "", before: 0, after: 0, to_length: 0},
meta: %{transforms: [%ExkPasswd.Transform.Romaji{}]}
)
ExkPasswd.generate(config)
#=> "sakura-sora-umi"

Pinyin and Romaji are deterministic and can be many-to-one. Unmapped characters pass through unchanged. Validate every custom language dictionary if the destination requires ASCII; the seen-entropy model counts the reachable transformed outputs rather than assuming every source word stays distinct.


Development

Quick Reference

# Setup
mix setup
# Complete required quality gate
mix check
# Useful focused commands while iterating
mix format
mix credo --strict
mix test
mix coveralls.html
mix dialyzer
mix doctor
mix docs
mix deps.audit

Benchmarks

mix bench.password
mix bench.dict
mix bench.batch
mix bench.all

The checked-in benchmark reports are measurements from one recorded environment, not API guarantees. Re-run the suites on the deployment runtime and hardware before drawing performance conclusions.

Interactive Contributor Guide

The contributor notebook is intentionally separate from the user tutorials:

Run the contributor guide in Livebook

It provides an executable architecture tour. The terminal quality gates remain the source of truth for contributions.


Contributing

Read CONTRIBUTING.md before opening a pull request. Keep security-sensitive changes small, document public APIs, and include tests for success, failure, and relevant edge cases.

Commit messages follow Conventional Commits:

feat: add a new preset
fix(random): reject biased buffered samples
docs: clarify the entropy model

Releasing

Releases are managed with git_ops:

  1. Run the complete project checks with mix check.
  2. Run mix release to update the changelog and version, commit, and tag.
  3. Push the release commit and tag with git push --follow-tags.

The publish workflow verifies the exact project-version tag and tagged commit, runs the same mix check gate, and exposes the Hex API key only to the final publish step.


Resources


Acknowledgments


License

ExkPasswd is available under the BSD 2-Clause License. See LICENSE.md.