183 lines
5.6 KiB
Python
183 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
XKCD “correct-horse-battery-staple” password generator.
|
||
|
||
Features
|
||
--------
|
||
* Uses the official EFF large word list (7776 words, ~12.9 bits/word).
|
||
* Caches the list in the user's cache directory.
|
||
* Fully type‑annotated, no external dependencies beyond the standard library.
|
||
* Optional extra digit / symbol and customizable separator.
|
||
"""
|
||
|
||
import hashlib
|
||
import os
|
||
import random
|
||
import sys
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from typing import List
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 1️⃣ Load / cache the word list
|
||
# ----------------------------------------------------------------------
|
||
WORDLIST_URL = (
|
||
# "https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt"
|
||
"https://github.com/first20hours/google-10000-english/raw/refs/heads/master/google-10000-english.txt"
|
||
)
|
||
CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "xkcd_passgen"
|
||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
# WORDLIST_PATH = CACHE_DIR / "eff_large_wordlist.txt"
|
||
WORDLIST_PATH = CACHE_DIR / "google-10000-english.txt"
|
||
|
||
|
||
def _download_wordlist() -> None:
|
||
"""Download the EFF word list to the cache directory."""
|
||
print("Downloading word list …", file=sys.stderr)
|
||
with urllib.request.urlopen(WORDLIST_URL) as resp, open(
|
||
WORDLIST_PATH, "wb"
|
||
) as out:
|
||
out.write(resp.read())
|
||
|
||
|
||
def _load_wordlist() -> List[str]:
|
||
"""Return the list of words (one per line, stripped)."""
|
||
if not WORDLIST_PATH.is_file():
|
||
_download_wordlist()
|
||
words = []
|
||
with open(WORDLIST_PATH, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
# The file format is: 12345 word
|
||
line = line.strip()
|
||
if " " in line:
|
||
parts = line.strip().split()
|
||
if len(parts) == 2:
|
||
words.append(parts[1])
|
||
else:
|
||
if len(line) > 4:
|
||
words.append(line)
|
||
if not words:
|
||
raise RuntimeError("Failed to load any words from the word list.")
|
||
return words
|
||
|
||
|
||
# Load once at import time – cheap after the first run
|
||
WORDLIST = _load_wordlist()
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 2️⃣ Core password generator
|
||
# ----------------------------------------------------------------------
|
||
def generate_password(
|
||
num_words: int = 4,
|
||
separator: str = " ",
|
||
capitalize: bool = False,
|
||
add_digit: bool = False,
|
||
add_symbol: bool = False,
|
||
rng: random.Random | None = None,
|
||
) -> str:
|
||
"""
|
||
Return a password consisting of *num_words* random words.
|
||
|
||
Parameters
|
||
----------
|
||
num_words:
|
||
Number of words to concatenate (default 4 → ~52 bits of entropy).
|
||
separator:
|
||
String placed between words (default space). Use ``''`` for a
|
||
“passphrase” without spaces.
|
||
capitalize:
|
||
Capitalise the first word (adds ~1 bit of entropy).
|
||
add_digit:
|
||
Append a random decimal digit (adds ~3.3 bits).
|
||
add_symbol:
|
||
Append a random symbol from ``!@#$%^&*()-_=+[]{};:,.?`` (adds ~5 bits).
|
||
|
||
Returns
|
||
-------
|
||
str
|
||
The generated password.
|
||
"""
|
||
rng = rng or random.SystemRandom() # cryptographically strong RNG
|
||
|
||
chosen = [rng.choice(WORDLIST) for _ in range(num_words)]
|
||
|
||
if capitalize:
|
||
chosen = [c.capitalize() for c in chosen]
|
||
# chosen[0] = chosen[0].capitalize()
|
||
|
||
password = separator.join(chosen)
|
||
|
||
if add_digit:
|
||
password += str(rng.randint(0, 9))
|
||
|
||
if add_symbol:
|
||
symbols = "!@#$%^&*()-_=+[]{};:,.?"
|
||
password += rng.choice(symbols)
|
||
|
||
return password
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 3️⃣ Helper to compute entropy (optional but nice to have)
|
||
# ----------------------------------------------------------------------
|
||
def password_entropy(num_words: int, add_digit: bool, add_symbol: bool) -> float:
|
||
"""
|
||
Approximate entropy in bits for the given configuration.
|
||
|
||
The EFF list has 7776 words → log₂(7776) ≈ 12.9 bits per word.
|
||
"""
|
||
bits_per_word = 12.9
|
||
entropy = num_words * bits_per_word
|
||
if add_digit:
|
||
entropy += 3.32 # log₂(10)
|
||
if add_symbol:
|
||
entropy += 5.0 # log₂(≈32 symbols)
|
||
return entropy
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 4️⃣ Command‑line interface (optional)
|
||
# ----------------------------------------------------------------------
|
||
def _cli() -> None:
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="Generate XKCD-style passphrases."
|
||
)
|
||
parser.add_argument(
|
||
"-n", "--num-words", type=int, default=4,
|
||
help="Number of words (default: 4)."
|
||
)
|
||
parser.add_argument(
|
||
"-s", "--separator", default=" ",
|
||
help="String placed between words (default: space)."
|
||
)
|
||
parser.add_argument(
|
||
"-c", "--capitalize", action="store_true",
|
||
help="Capitalize the first word."
|
||
)
|
||
parser.add_argument(
|
||
"-d", "--digit", action="store_true",
|
||
help="Append a random digit."
|
||
)
|
||
parser.add_argument(
|
||
"-S", "--symbol", action="store_true",
|
||
help="Append a random symbol."
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
pwd = generate_password(
|
||
num_words=args.num_words,
|
||
separator=args.separator,
|
||
capitalize=args.capitalize,
|
||
add_digit=args.digit,
|
||
add_symbol=args.symbol,
|
||
)
|
||
print(pwd)
|
||
# print(f"≈ {password_entropy(args.num_words, args.digit, args.symbol):.1f} bits of entropy")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_cli()
|