Powered by AppSignal & Oban Pro

Contributing to ExkPasswd

notebooks/contributing.livemd

Contributing to ExkPasswd

Mix.install([
  {:exk_passwd, path: Path.join(__DIR__, "..")}
])

Introduction

Welcome to ExkPasswd development! This interactive notebook helps you understand the codebase architecture, run examples, and test changes in real-time.

Why use this notebook?

  • Interactive exploration - Call functions, inspect results, experiment with code
  • Immediate feedback - Test your changes without running the full test suite
  • Architecture understanding - See how modules interact with each other
  • Safe experimentation - Try ideas without modifying the actual codebase

Project Structure

ExkPasswd follows a clean, modular architecture:

lib/exk_passwd/
├── config/               # Configuration system
│   ├── presets.ex       # Built-in presets (Agent-based)
│   └── schema.ex        # Configuration validation
├── transform/            # Transform protocol implementations
│   ├── case_transform.ex
│   └── substitution.ex
├── config.ex            # Configuration struct (schema-driven)
├── dictionary.ex        # ETS-backed word storage (O(1) lookups)
├── password.ex          # Core password generation engine
├── batch.ex             # Optimized batch generation
├── token.ex             # Random number/symbol generation
├── buffer.ex            # Buffered random bytes for performance
├── entropy.ex           # Entropy calculation
├── strength.ex          # Password strength analysis
├── transform.ex         # Transform protocol definition
├── validator.ex         # Configuration validation
└── random.ex            # Cryptographically secure random utilities

Architecture Overview

Let’s explore how the core components work together:

# 1. Configuration (schema-driven, validated)
config = ExkPasswd.Config.new!(
  num_words: 3,
  word_length: 4..6,
  separator: "-",
  case_transform: :capitalize
)

IO.inspect(config, label: "Config struct")
# 2. Dictionary (ETS-backed for custom dictionaries, compile-time for :eff)
IO.puts("Dictionary size: #{ExkPasswd.Dictionary.size()}")
IO.puts("Min word length: #{ExkPasswd.Dictionary.min_length()}")
IO.puts("Max word length: #{ExkPasswd.Dictionary.max_length()}")

# Count words in range
count = ExkPasswd.Dictionary.count_between(4, 6)
IO.puts("Words between 4-6 chars: #{count}")

# Get a random word
word = ExkPasswd.Dictionary.random_word_between(4, 6)
IO.puts("Random word (4-6): #{word}")
# 3. Password generation (orchestrates Dictionary + Config + Transforms)
password = ExkPasswd.Password.create(config)
IO.puts("Generated: #{password}")

# Generate a few more to see variety
for _ <- 1..5 do
  ExkPasswd.Password.create(config)
end
# 4. Custom Dictionaries (ETS-backed, O(1) operations)
# Load a custom dictionary
custom_words = ["alpha", "bravo", "charlie", "delta", "echo"]
ExkPasswd.Dictionary.load_custom(:military, custom_words)

# Use it
custom_config = ExkPasswd.Config.new!(
  num_words: 3,
  separator: "-",
  dictionary: :military
)

ExkPasswd.Password.create(custom_config)

Key Design Patterns

1. Schema-Driven Configuration

The Config module uses schema-based validation for fail-fast error handling:

# Valid config
{:ok, valid_config} = ExkPasswd.Config.new(num_words: 3)
IO.inspect(valid_config)

# Invalid config - immediate feedback
case ExkPasswd.Config.new(num_words: 0) do
  {:ok, _} -> IO.puts("Should not happen")
  {:error, msg} -> IO.puts("Error: #{msg}")
end

2. Transform Protocol

The Transform protocol allows extensibility without modifying core code:

# Built-in substitution transform
config_with_transform = ExkPasswd.Config.new!(
  num_words: 3,
  word_length: 4..6,
  separator: "-",
  meta: %{
    transforms: [
      %ExkPasswd.Transform.Substitution{
        map: %{"a" => "@", "e" => "3", "o" => "0"},
        mode: :random
      }
    ]
  }
)

# Generate with transforms applied
for _ <- 1..3 do
  ExkPasswd.Password.create(config_with_transform)
  |> IO.puts()
end

3. O(1) Dictionary Lookups

Dictionary uses a tuple-based structure for constant-time operations:

# All these operations are O(1)
:timer.tc(fn ->
  for _ <- 1..10_000 do
    ExkPasswd.Dictionary.random_word_between(4, 8)
  end
end)
|> then(fn {microseconds, _result} ->
  IO.puts("10,000 random word selections: #{microseconds / 1000}ms")
  IO.puts("Average: #{microseconds / 10_000} microseconds per selection")
end)

4. Buffered Random Generation

Batch generation uses buffered random bytes for performance:

# Compare batch vs individual generation
count = 100

# Individual generation
{individual_time, _individual_passwords} =
  :timer.tc(fn ->
    for _ <- 1..count do
      ExkPasswd.generate()
    end
  end)

# Batch generation (buffered random)
{batch_time, _batch_passwords} =
  :timer.tc(fn ->
    ExkPasswd.generate_batch(count)
  end)

speedup = individual_time / batch_time

IO.puts("Individual: #{individual_time / 1000}ms")
IO.puts("Batch: #{batch_time / 1000}ms")
IO.puts("Speedup: #{Float.round(speedup, 2)}x faster")

Testing Your Changes

After modifying code in lib/, recompile and test immediately:

# Recompile the project
IEx.Helpers.recompile()

# Test your changes
ExkPasswd.generate()

Security Verification

Verify cryptographic randomness:

# Generate 1000 passwords and check uniqueness
passwords =
  for _ <- 1..1000 do
    ExkPasswd.generate()
  end

unique_count = passwords |> Enum.uniq() |> length()
collision_rate = (1000 - unique_count) / 1000 * 100

IO.puts("Generated: 1000 passwords")
IO.puts("Unique: #{unique_count}")
IO.puts("Collision rate: #{Float.round(collision_rate, 4)}%")
IO.puts("(Should be near 0% for good randomness)")

Entropy Analysis

Understanding entropy calculations:

config = ExkPasswd.Config.new!(num_words: 4, separator: "-")
password = ExkPasswd.Password.create(config)

entropy = ExkPasswd.Entropy.calculate(password, config)

IO.puts("Password: #{password}")
IO.puts("\nEntropy Analysis:")
IO.inspect(entropy, pretty: true)

Performance Testing

Quick performance verification:

# Benchmark password generation
{time, _password} = :timer.tc(fn -> ExkPasswd.generate() end)
IO.puts("Single password: #{time} microseconds")

# Benchmark dictionary lookup
{time, _word} = :timer.tc(fn -> ExkPasswd.Dictionary.random_word_between(4, 8) end)
IO.puts("Dictionary lookup: #{time} microseconds (should be < 1μs for O(1))")

Common Development Tasks

Adding a New Preset

# 1. Create config
my_preset = ExkPasswd.Config.new!(
  num_words: 5,
  word_length: 3..5,
  separator: "_",
  case_transform: :upper,
  digits: {3, 3},
  padding: %{char: "!", before: 2, after: 2, to_length: 0}
)

# 2. Register it
ExkPasswd.Config.Presets.register(:my_strong, my_preset)

# 3. Use it
ExkPasswd.generate(:my_strong)

Creating a Custom Transform

defmodule DevTransform do
  @moduledoc "Adds dev-friendly markers"
  defstruct [:marker]

  defimpl ExkPasswd.Transform do
    def apply(%{marker: marker}, word, _config) do
      "#{marker}#{word}#{marker}"
    end

    def entropy_bits(%{marker: _marker}, _config) do
      # Deterministic, no additional entropy
      0.0
    end
  end
end

# Use it
dev_config =
  ExkPasswd.Config.new!(
    num_words: 3,
    meta: %{
      transforms: [%DevTransform{marker: ">>"}]
    }
  )

ExkPasswd.Password.create(dev_config)

Testing Edge Cases

# Minimum configuration
min_config = ExkPasswd.Config.new!(
  num_words: 1,
  word_length: 3..3,
  separator: "",
  digits: {0, 0},
  padding: %{char: "", before: 0, after: 0, to_length: 0}
)

IO.inspect(ExkPasswd.Password.create(min_config), label: "Minimum password")

# Maximum configuration
max_config = ExkPasswd.Config.new!(
  num_words: 10,
  word_length: 3..9,
  separator: "-+-",
  case_transform: :random,
  digits: {5, 5},
  padding: %{char: "★", before: 5, after: 5, to_length: 0}
)

IO.inspect(ExkPasswd.Password.create(max_config), label: "Maximum password")

Running Tests

After making changes, run the test suite:

# In your terminal:
# mix test                    # All tests
# mix test --stale            # Only changed tests
# mix coveralls.html          # With coverage
# mix test test/path/file_test.exs:123  # Specific test

Code Quality Checks

Before submitting a PR:

# In your terminal:
# mix format                  # Format code
# mix credo --strict          # Linting
# mix dialyzer                # Type checking
# mix docs                    # Generate docs
# mix bench.all               # Run benchmarks

Next Steps

  1. Read the test files - They show how each module is used
  2. Experiment with this notebook - Try different configurations
  3. Check CLAUDE.md in the repository - Agent guidelines and best practices
  4. Review existing code - Small, focused modules are easy to understand
  5. Ask questions - Open an issue if something is unclear

Resources


Happy contributing! 🚀