Password Generation Is About Entropy, Not Randomness
I built pwgen-rs, a small Rust CLI that generates passwords in three modes and reports the bit-count of entropy for every one. Writing it forced me to answer a question I had been handwaving for years: what does "a strong password" actually mean?
π¦ GitHub: https://github.com/sen-ltd/pwgen-rs
Every developer I know generates passwords the same way: some combination of openssl rand -base64 24, a password manager's "generate" button, and β for the truly nostalgic β the old Debian pwgen(1) binary. None of these tools answer the question I actually care about: how many guesses would it take to find this password?
That number has a name. It's the Shannon entropy, in bits, of the distribution the password was drawn from. For a uniform draw of length L from an alphabet of N distinct characters, the formula is embarrassingly simple:
H = log2(N^L) = L * log2(N)
A 16-character password drawn from a 76-character alphabet has 16 * log2(76) β 99.97 bits. A 5-word passphrase from the 1296-word EFF Short Wordlist has 5 * log2(1296) β 51.7 bits. A 10-character "pronounceable" password (old pwgen style, alternating consonants and vowels) has 5 * log2(21) + 5 * log2(5) β 33.6 bits β because at each position you're sampling from a pool of 21 or 5, not 76.
Those three numbers mean three very different things, and the whole point of pwgen-rs is that you can see them.
Section 1: The problem
openssl rand gives you bytes, not passwords. It has no concept of a character class, no mode for pronounceable output, no way to generate a passphrase from a wordlist, and β crucially β no entropy reporting. You have to compute that yourself from how many bytes you asked for and which encoding you applied, and most people just don't bother.
Debian's pwgen(1) is the classic pronounceable-password tool. It is unmaintained as of this writing, its flags are cryptic (pwgen -cnys 16 5, anyone?), and its default pronounceable output is worryingly low-entropy for its length. It predates both CSPRNGs being a widespread concern and the idea that users should see how strong their password is.
Password managers have generators, but they're modal UI affairs. You can't pipe them into kubectl create secret generic ... --from-literal=password=$(...). They're also all-or-nothing on entropy reporting: some show a colored bar, some show nothing, none show the actual bit count.
What I wanted was a focused CLI that:
- Takes the CSPRNG question seriously. Not "probably fine", actually the OS CSPRNG for every byte.
- Supports three modes (random, pronounceable, diceware) because memorability trades off against entropy and the right tradeoff depends on the use case.
- Reports entropy in bits for every password it emits, so you see what you're getting.
- Prints to stdout so it can be piped anywhere.
Section 2: Design β CSPRNG, entropy math, mode tradeoffs
Why OsRng and not thread_rng()
The rand crate gives you two obvious CSPRNGs: rand::thread_rng() and rand::rngs::OsRng. Both are cryptographically secure β this is not a "one is broken" situation. But they differ in one important way:
-
OsRngis a thin wrapper aroundgetrandom(2)on Linux,BCryptGenRandomon Windows,SecRandomCopyByteson macOS. Every byte it emits came straight from the kernel's CSPRNG. -
thread_rng()is a user-space PRNG (ChaCha12 in current versions) that is seeded fromOsRngand reseeds periodically. It's designed to be fast for code that needs a lot of random numbers.
For a password generator, that speed argument is irrelevant β we want tens of bytes, not tens of megabytes. And the principle of minimizing attack surface says: the less user-space code sits between getrandom and your output, the fewer audits you have to trust. So pwgen-rs uses OsRng directly:
use rand::rngs::OsRng;
use rand::seq::SliceRandom;
pub fn generate(alphabet: &[char], length: usize) -> String {
assert!(!alphabet.is_empty(), "alphabet must be non-empty");
let mut rng = OsRng;
let mut out = String::with_capacity(length);
for _ in 0..length {
let c = alphabet.choose(&mut rng).copied().unwrap_or(alphabet[0]);
out.push(c);
}
out
}
A small but important detail: SliceRandom::choose handles modulo bias correctly for slices whose length isn't a power of two. The naΓ―ve rng.next_u32() % alphabet.len() introduces a bias because 2^32 % 76 != 0; choose rejects out-of-range values and retries. It sounds theoretical but it's one of the textbook ways a "random" implementation can leak a few bits.
Computing entropy correctly
The entropy module is maybe 30 lines of real code, and most of it is input sanitization:
pub fn random_bits(alphabet_size: usize, length: usize) -> f64 {
if alphabet_size < 2 || length == 0 {
return 0.0;
}
(length as f64) * (alphabet_size as f64).log2()
}
pub fn diceware_bits(wordlist_size: usize, words: usize) -> f64 {
if wordlist_size < 2 || words == 0 {
return 0.0;
}
(words as f64) * (wordlist_size as f64).log2()
}
pub fn per_position_bits(pool_sizes: &[usize]) -> f64 {
pool_sizes
.iter()
.filter(|&&n| n >= 2)
.map(|&n| (n as f64).log2())
.sum()
}
The third function exists for pronounceable mode, where the pool at each position depends on whether you're about to emit a consonant or a vowel. You can't just do length * log2(alphabet) because there's no single alphabet β consonant positions sample from 21 characters, vowel positions from 5. The correct answer is the sum of log2(pool_size) over all positions. For a 10-character pronounceable password that's 5 * log2(21) + 5 * log2(5) β 21.95 + 11.61 β 33.56 bits β less than half of what you'd get for 10 characters of real random output.
This is why I find it important that the tool shows the number. A user who sees "33 bits" next to a 10-character pronounceable password immediately understands that the memorability came at a cost. The same user staring at a 10-character pronounceable password with no label is a user who thinks they got something stronger than they actually did.
There are unit tests for all the known-value entropy calculations. For instance:
#[test]
fn random_bits_95_char_alphabet_12_chars_is_about_78_8() {
// log2(95) β 6.5699, * 12 β 78.8388
let bits = random_bits(95, 12);
assert!((bits - 78.8388).abs() < 0.01, "got {}", bits);
}
These exist partly as a sanity check on the math and partly as executable documentation: anyone reading the test knows exactly what random_bits(95, 12) is supposed to return.
Diceware vs random at the same entropy
Classic Diceware (Arnold Reinhold's 1995 design) uses a wordlist of 7776 words β exactly 6^5, because you roll five dice to pick each word. That gives log2(7776) β 12.925 bits per word. So a standard 6-word Diceware passphrase is about 77.5 bits β roughly what you'd get from 12 characters of pwgen-rs default-alphabet random output.
But the two passwords feel completely different. correct horse battery staple is easier to type and remember than =AV6lBSfxwh. Same rough entropy, vastly different UX. This is why diceware mode exists.
pwgen-rs uses the EFF Short Wordlist, not the classic Diceware list. The EFF list is 1296 words (6^4, down from 6^5), averaging 4.5 letters per word, with no word being a prefix of another, no profanity, and a curated focus on memorable monosyllabic words. You get log2(1296) β 10.34 bits per word instead of 12.9 β a loss of about 2.6 bits per word that you recover by adding one extra word. A 6-word EFF-short phrase is ~62 bits, a 7-word one is ~72.4, an 8-word one is ~82.7.
I like the tradeoff because the words are much easier to type. The list is embedded at compile time with include_str!:
const WORDLIST_RAW: &str = include_str!("../data/eff_short_wordlist.txt");
pub fn load_wordlist() -> Vec<&'static str> {
WORDLIST_RAW
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect()
}
pub fn generate(words: usize) -> String {
let list = load_wordlist();
let mut rng = OsRng;
let mut out = Vec::with_capacity(words);
for _ in 0..words {
let w = list.choose(&mut rng).copied().unwrap_or("");
out.push(w);
}
out.join(" ")
}
Two things worth pointing out. First, the wordlist adds about 8 KB to the binary and zero runtime cost β it's parsed once per invocation and this is a one-shot CLI, so I didn't bother with a lazy static. Second, the EFF list is CC-BY 3.0 US, which I credit in the LICENSE and README; it's the kind of attribution that takes two minutes and matters because the EFF did real work designing that list.
Strength labels
Bits are the honest answer but not the friendly one. pwgen-rs also emits a coarse label based on a rubric that matches how security commentary (NIST SP 800-63B, OWASP, most password-manager UIs) talks about strength:
-
< 40 bitsβ weak -
40β60β reasonable -
60β80β strong -
80+β very strong
The default invocation (16 chars, full alphabet, ~100 bits) lands in "very strong". A 5-word diceware phrase is "reasonable". A 10-char pronounceable password is "weak". All of those are defensible for different use cases, and the CLI doesn't refuse to emit weak passwords β it just tells you what you asked for.
Section 3: Tradeoffs and non-goals
A few things pwgen-rs deliberately does not do:
- Pronounceable mode is lower entropy for the same length. This is advertised, not a bug, and is the main reason to prefer diceware if memorability is the actual goal.
-
--no-similarreduces the alphabet by 7 characters. Droppingil1Lo0Otakes the default 76-character alphabet down to 69, which costs about 0.14 bits per character β for a 16-char password that's about 2.2 bits lost. Usually worth it for legibility, but worth knowing. -
The EFF wordlist is English only. If you want a Japanese or German wordlist, you'd add a flag and a second
include_str!; I stopped at one because the article is about the design principle, not wordlist pluralism. -
No "must contain one symbol" rules. Those composition rules are a symptom of not measuring entropy. If your password is high-entropy, you don't need to mandate a symbol; if it isn't, mandating a symbol barely helps. NIST SP 800-63B explicitly moved away from composition rules in 2017 and
pwgen-rsfollows that lead. -
Output goes to stdout with no clipboard integration. Shells already have
xclip/pbcopy.pwgen-rs --format json | jq -r .password | pbcopyis five seconds of UNIX composition.
Section 4: Try it in 30 seconds
docker run --rm ghcr.io/sen-ltd/pwgen-rs
# One 16-character password from a 76-char alphabet (~100 bits)
docker run --rm ghcr.io/sen-ltd/pwgen-rs 5
# Five passwords
docker run --rm ghcr.io/sen-ltd/pwgen-rs --length 24 --no-symbol
# Letters and digits only
docker run --rm ghcr.io/sen-ltd/pwgen-rs --mode diceware --words 7
# Seven-word EFF-short passphrase (~72 bits)
docker run --rm ghcr.io/sen-ltd/pwgen-rs --mode pronounceable --length 12
docker run --rm ghcr.io/sen-ltd/pwgen-rs --format json
# { "password": "...", "entropy_bits": 99.97, "strength": "very strong" }
The final Docker image is about 9.5 MB (multi-stage rust:1.85-alpine builder, stripped release binary with LTO and panic=abort, alpine:3.20 runtime as a non-root pwgen user). 51 tests (28 unit + 23 assert_cmd integration) cover every flag, every mode, entropy values for known inputs, JSON output shape, and exit codes 0/1/2.
If you were ever going to ssh-keygen a passphrase, this is what I use now. Entropy is the real number. Everything else is UX.

Top comments (0)