Skip to content

Instantly share code, notes, and snippets.

@arachsys
Last active August 10, 2026 09:03
Show Gist options
  • Select an option

  • Save arachsys/e01ad4d02af663138498460833e4c764 to your computer and use it in GitHub Desktop.

Select an option

Save arachsys/e01ad4d02af663138498460833e4c764 to your computer and use it in GitHub Desktop.
Generate phonetic passwords with reliable entropy
#!/bin/python
import functools, math, os, secrets, sys
letters = list('abcdefghijklmnopqrstuvwxyz')
digits = list('0123456789')
vowels = list('aeiou')
consonants = [ c for c in letters if c not in vowels ]
clusters = { c + 'y' for c in consonants if c not in 'qy' } | {
'bl', 'br', 'ch', 'cl', 'cr', 'dr', 'dw', 'fl', 'fr', 'gl', 'gn', 'gr',
'kn', 'ph', 'pl', 'pr', 'sc', 'sh', 'sk', 'sl', 'sm', 'sn', 'sp', 'st',
'sw', 'th', 'tr', 'tw', 'wh', 'wr'
}
@functools.cache
def count(free, last, repeat, length):
return 1 if free < 1 else sum(count(free - 1, *state)
for _, *state in moves(last, repeat, length))
def moves(last, repeat, length, small = 3):
if length >= small:
for next in digits:
yield next, next, False, 0
else:
length += 1
if last in consonants:
for next in consonants:
if not repeat and last + next in clusters:
yield next, next, True, length
for next in vowels:
yield next, next, False, length
elif last in vowels:
if not repeat:
for next in vowels:
yield next, next, True, length
for next in consonants:
yield next, next, False, length
else:
for next in letters:
yield next.upper(), next, False, length
yield next, next, False, length
def password(length, random = secrets.randbelow):
choice = random(count(length, None, False, 0))
result, *state = '', None, False, 0
while free := length - len(result):
for next, *state in moves(*state):
if choice < count(free - 1, *state):
result += next
break
choice -= count(free - 1, *state)
return result
try:
length, repeat = 12, 1
if len(sys.argv) > 1:
length = max(0, int(sys.argv[1]))
if len(sys.argv) > 2:
repeat = max(0, int(sys.argv[2]))
elif sys.stdout.isatty():
repeat = 8
except Exception:
print(f'Usage: {os.path.basename(sys.argv[0])} [LENGTH [COUNT]]',
file = sys.stderr)
sys.exit(64)
entropy = math.log2(count(length, None, False, 0))
for _ in range(repeat):
print(f'{password(length)} {entropy:0.1f} bits')
@arachsys

arachsys commented Aug 9, 2026

Copy link
Copy Markdown
Author

Here, every permitted output of a given length is selected with uniform probability by construction, so surprise is constant, unlike the original phonetic pwgen, for which some outputs were 100x more likely than others.

There are $2^{53.3}$ possible outputs of length 12, each of which is selected with probability $2^{-53.3}$. 11 character outputs have slightly higher entropy than 8 characters selected uniformly from [A-Za-z0-9]{8}.

@arachsys

arachsys commented Aug 9, 2026

Copy link
Copy Markdown
Author

Most of the cost of generating a password of a given length is constructing the count() cache, so generating 100 passwords doesn't require much more effort than a single output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment