Powered by AppSignal & Oban Pro

ExkPasswd security model

notebooks/security.livemd

ExkPasswd security model

Mix.install([
  {:exk_passwd, "~> 0.2.0"}
])

This notebook separates generator-aware min-entropy from a naive brute-force search-space estimate. Neither number predicts a specific online service or password hash.

Compare the built-in presets

presets = [:default, :xkcd, :web32, :web16, :wifi, :apple_id, :security]

for preset <- presets do
  config = ExkPasswd.Config.Presets.get(preset)
  password = ExkPasswd.generate(config)
  report = ExkPasswd.Entropy.calculate(password, config)

  %{
    preset: preset,
    length: String.length(password),
    seen_min_entropy: Float.round(report.seen, 1),
    blind_estimate: Float.round(report.blind, 1),
    rating: report.status
  }
end

The project rating bands are convenience labels; they are not NIST or OWASP thresholds. Current NIST guidance focuses on verifier behavior such as minimum length, blocklists, rate limiting, and avoiding composition rules for user-chosen passwords.

Inspect an entropy breakdown

config = ExkPasswd.Config.Presets.get(:default)
ExkPasswd.Entropy.calculate_seen_detailed(config)

The calculation assumes the attacker knows the dictionary and configuration. It includes known output collisions and only credits random decisions that can produce distinct values.

Word-count effect

for count <- 2..6 do
  config =
    ExkPasswd.Config.new!(
      num_words: count,
      case_transform: :lower,
      separator: "-",
      digits: {0, 0},
      padding: %{char: "", before: 0, after: 0, to_length: 0}
    )

  {count, Float.round(ExkPasswd.Entropy.calculate_seen(config), 2)}
end

More independent selections from the same pool increase the configured output space linearly in bits. Longer words do not automatically help: restricting the length range can reduce the number of eligible dictionary entries.

A collision caused by a transform

ExkPasswd.Dictionary.load_custom(:pinyin_collision_demo, ["是", "事"])

config =
  ExkPasswd.Config.new!(
    num_words: 1,
    dictionary: :pinyin_collision_demo,
    word_length: 1..1,
    word_length_bounds: 1..10,
    case_transform: :none,
    separator: "",
    digits: {0, 0},
    padding: %{char: "", before: 0, after: 0, to_length: 0},
    meta: %{transforms: [%ExkPasswd.Transform.Pinyin{}]}
  )

passwords = for _ <- 1..10, do: ExkPasswd.generate(config)
{Enum.uniq(passwords), ExkPasswd.Entropy.calculate_seen_detailed(config)}

Both source characters become shi, so the transformed word contributes zero bits. Deterministic complexity is not randomness.

Minimum-length padding does not discard entropy

config =
  ExkPasswd.Config.new!(
    num_words: 5,
    word_length: 8..9,
    padding: %{char: "=", before: 0, after: 0, to_length: 16}
  )

password = ExkPasswd.generate(config)
{String.length(password), password}

The output remains longer than 16; ExkPasswd never silently truncates generated words or digits.

Uniqueness is not a randomness proof

config = ExkPasswd.Config.Presets.get(:xkcd)
passwords = ExkPasswd.Batch.generate_batch(2_000, config)

%{
  generated: length(passwords),
  unique: length(Enum.uniq(passwords))
}

Few collisions are expected in a large output space, but this experiment cannot prove cryptographic security or uniformity. Those properties depend on reviewed use of :crypto.strong_rand_bytes/1 and rejection sampling.

Operational guidance

  • Use a password manager and a unique password per account.
  • Enable MFA for important accounts.
  • Test a destination’s real length and character handling.
  • Do not silently truncate passwords during storage or verification.
  • Check user-chosen passwords against a breached/common-password blocklist at the verifier; ExkPasswd does not perform that network- or dataset-dependent step.
  • Use a slow, memory-hard password hash for stored credentials.

Further reading: