Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ jobs:
run: uv run pytest tests/ -v

- name: Type check (optional)
run: uv run python -m py_compile main.py web.py
run: uv run python -m py_compile main.py web.py passphrases.py
continue-on-error: true
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The displayed entropy is a pool-size estimate for the selected generator setting

- Customizable uppercase, lowercase, number, and symbol sets
- Ambiguous-character exclusion and custom exclusions
- Memorable passphrases with configurable separators
- Memorable passphrases from EFF's 7,776-word list with configurable separators
- Interactive prompts and a quick-generation mode
- Strength analysis and weak-pattern warnings
- Optional clipboard copy
Expand All @@ -39,7 +39,8 @@ The displayed entropy is a pool-size estimate for the selected generator setting

### Security defaults

- Passwords are generated with Python's cryptographically secure `secrets` module.
- Random choices use Python's `secrets` module.
- Passphrases default to six independently selected EFF words (about 77.5 bits).
- The web interface returns generated values only to the current page and does not write them to history.
- CLI history is written only when `--save-history` is supplied.
- History and exports receive owner-only permissions on POSIX systems.
Expand Down Expand Up @@ -80,8 +81,8 @@ uv run python main.py
# 20-character password using every character class
uv run python main.py --length 20 --upper --lower --digits --symbols

# Four-word capitalized passphrase
uv run python main.py --passphrase --words 4 --capitalize
# Six-word capitalized passphrase
uv run python main.py --passphrase --words 6 --capitalize

# Generate and copy one strong 20-character password
uv run python main.py --quick
Expand All @@ -105,7 +106,7 @@ uv run python main.py --length 20 --upper --lower --digits --symbols --save-hist
| `--copy` | Copy the generated value | Off |
| `--interactive` | Force interactive mode | Off |
| `--passphrase` | Generate a passphrase | Off |
| `--words N` | Number of passphrase words | `4` |
| `--words N` | Number of passphrase words | `6` |
| `--separator SEP` | Separator between words | `-` |
| `--capitalize` | Capitalize passphrase words | Off |
| `--add-number` | Append a number to the passphrase | Off |
Expand Down Expand Up @@ -143,7 +144,7 @@ uv sync --all-extras
uv run pytest tests/ -v

# Compile-check the Python entry points
uv run python -m py_compile main.py web.py
uv run python -m py_compile main.py web.py passphrases.py
```

For pull requests targeting `main`, GitHub Actions runs the test suite and a non-blocking Python compilation check.
Expand All @@ -155,11 +156,16 @@ password-generator/
├── .github/workflows/ci.yml
├── docs/web-ui.png
├── main.py
├── passphrases.py
├── web.py
├── templates/index.html
├── tests/
│ ├── test_main.py
│ ├── test_passphrases.py
│ └── test_web.py
├── wordlists/
│ ├── eff_large_wordlist.txt
│ └── README.md
├── pyproject.toml
└── README.md
```
Expand Down
38 changes: 17 additions & 21 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from rich import box
from datetime import datetime

from passphrases import BUILTIN_WORDLIST, calculate_passphrase_entropy

CONFIG_DIR = Path.home() / ".passgen"
HISTORY_FILE = CONFIG_DIR / "history.json"
CONFIG_FILE = CONFIG_DIR / "config.json"
Expand All @@ -41,22 +43,7 @@
console = Console(theme=custom_theme)
AMBIGUOUS_CHARS = set("0O1lI")

DEFAULT_WORDLIST = [
"apple", "banana", "cherry", "dragon", "eagle", "forest", "garden", "harbor",
"island", "jungle", "knight", "lemon", "mountain", "night", "ocean", "planet",
"quiet", "river", "sunset", "thunder", "umbrella", "volcano", "winter", "yellow",
"zebra", "anchor", "breeze", "castle", "diamond", "ember", "falcon", "glacier",
"harmony", "ivory", "jasmine", "kingdom", "lantern", "marble", "nebula", "orchid",
"phoenix", "quartz", "rainbow", "silver", "tiger", "unicorn", "velvet", "willow",
"xenon", "yacht", "azure", "bronze", "copper", "dawn", "echo", "frost",
"golden", "horizon", "iris", "jade", "karma", "lotus", "mystic", "nectar",
"opal", "pearl", "quest", "ruby", "storm", "topaz", "ultra", "vivid",
"whisper", "xray", "zenith", "amber", "blaze", "cloud", "dream", "energy",
"flare", "glow", "halo", "ink", "joy", "kindle", "light", "mist",
"nova", "orbit", "prism", "ray", "spark", "trail", "unity", "vapor",
"wave", "xerox", "yonder", "zest"
]

DEFAULT_WORDLIST = BUILTIN_WORDLIST
PASSPHRASE_WORDLIST = DEFAULT_WORDLIST

VERSION = "1.0.0"
Expand Down Expand Up @@ -301,7 +288,7 @@ def generate_passphrase(
passphrase = separator.join(words)

if include_number:
number = str(secrets.randbelow(100))
number = f"{secrets.randbelow(100):02d}"
passphrase += number

pool_size = len(PASSPHRASE_WORDLIST)
Expand Down Expand Up @@ -439,7 +426,7 @@ def get_interactive_options():
console.print(f"[warning]{e}. Using default wordlist.[/warning]")

try:
word_count = int(console.input("\n[info]Number of words (default 4): [/info]") or 4)
word_count = int(console.input("\n[info]Number of words (default 6): [/info]") or 6)
separator = console.input("[info]Separator (default -): [/info]") or "-"
capitalize = console.input("[info]Capitalize words? [Y/n]: [/info]").lower() in ("", "y")
include_number = console.input("[info]Add a number at the end? [Y/n]: [/info]").lower() in ("", "y")
Expand All @@ -448,7 +435,7 @@ def get_interactive_options():
copy_to_clipboard = console.input("[info]Copy to clipboard? [y/N]: [/info]").lower() == "y"
except ValueError:
console.print("[warning]Invalid input. Using defaults.[/warning]")
word_count, separator, capitalize, include_number = 4, "-", True, False
word_count, separator, capitalize, include_number = 6, "-", True, False
count, copy_to_clipboard = 1, False
category = ""

Expand Down Expand Up @@ -591,7 +578,7 @@ def main():
parser.add_argument("--copy", action="store_true", help="Copy password to clipboard")
parser.add_argument("--interactive", action="store_true", help="Run in interactive mode")
parser.add_argument("--passphrase", action="store_true", help="Generate passphrase instead of random password")
parser.add_argument("--words", type=int, default=4, help="Number of words for passphrase")
parser.add_argument("--words", type=int, default=6, help="Number of words for passphrase")
parser.add_argument("--separator", type=str, default="-", help="Separator for passphrase words")
parser.add_argument("--capitalize", action="store_true", help="Capitalize passphrase words")
parser.add_argument("--add-number", action="store_true", help="Add number at end of passphrase")
Expand Down Expand Up @@ -688,6 +675,8 @@ def main():

is_interactive = args.interactive or len(sys.argv) == 1
result = None
word_count = 0
include_number = False

if is_interactive:
result = get_interactive_options()
Expand Down Expand Up @@ -792,7 +781,14 @@ def main():
table.add_column("Entropy", justify="right")

for i, (pwd, pool_size) in enumerate(passwords, 1):
entropy = calculate_entropy(pwd, pool_size)
if passphrase_mode:
entropy = calculate_passphrase_entropy(
word_count,
pool_size,
number_choices=100 if include_number else 1,
)
else:
entropy = calculate_entropy(pwd, pool_size)
strength, color = get_strength_label(entropy)
p_type = "Passphrase" if passphrase_mode else "Random"

Expand Down
54 changes: 54 additions & 0 deletions passphrases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Shared passphrase wordlist loading and entropy calculation."""

from functools import lru_cache
import math
from pathlib import Path

EXPECTED_BUILTIN_WORD_COUNT = 7776
BUILTIN_WORDLIST_PATH = (
Path(__file__).resolve().parent / "wordlists" / "eff_large_wordlist.txt"
)


@lru_cache(maxsize=None)
def load_wordlist(
path: Path | str = BUILTIN_WORDLIST_PATH,
expected_count: int = EXPECTED_BUILTIN_WORD_COUNT,
) -> tuple[str, ...]:
"""Load and validate a one-word-per-line passphrase wordlist once."""
wordlist_path = Path(path)
words = tuple(
line.strip()
for line in wordlist_path.read_text(encoding="utf-8").splitlines()
if line.strip()
)

if len(words) != expected_count:
raise ValueError(
f"Invalid wordlist: expected {expected_count} words, found {len(words)}"
)
if len(set(words)) != len(words):
raise ValueError("Invalid wordlist: duplicate words found")
if any(
word != word.lower()
or not word.isascii()
or not word.replace("-", "").isalpha()
for word in words
):
raise ValueError("Invalid wordlist: entries must be lowercase ASCII words")

return words


def calculate_passphrase_entropy(
word_count: int,
pool_size: int,
number_choices: int = 1,
) -> float:
"""Return entropy from independent word and optional suffix choices."""
if word_count <= 0 or pool_size <= 0 or number_choices <= 0:
raise ValueError("Entropy inputs must be positive")
return (word_count * math.log2(pool_size)) + math.log2(number_choices)


BUILTIN_WORDLIST = load_wordlist()
2 changes: 1 addition & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ <h2>Configure</h2>
<div class="field-row">
<div class="field">
<label class="field-label" for="word-count"><span>Words</span><span class="field-hint">2–10</span></label>
<input id="word-count" type="number" name="word_count" value="4" min="2" max="10">
<input id="word-count" type="number" name="word_count" value="6" min="2" max="10">
</div>
<div class="field">
<label class="field-label" for="separator">Separator</label>
Expand Down
47 changes: 47 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ def test_generate_password_raises_on_short_length(self):


class TestPassphraseGeneration:
def test_default_passphrase_uses_eff_wordlist(self):
pwd, pool = generate_passphrase(6, "-", False, False)

assert pool == 7776
assert all(word in PASSPHRASE_WORDLIST for word in pwd.split("-"))

def test_generate_passphrase_default(self):
pwd, pool = generate_passphrase(4, "-", True, False)
assert len(pwd.split("-")) == 4
Expand All @@ -76,6 +82,13 @@ def test_generate_passphrase_with_number(self):
pwd, pool = generate_passphrase(4, "-", True, True)
assert pwd[-1].isdigit()

def test_generate_passphrase_uses_fixed_width_number_suffix(self, monkeypatch):
monkeypatch.setattr(main_module.secrets, "randbelow", lambda _: 7)

pwd, _ = generate_passphrase(4, "-", False, True)

assert pwd.endswith("07")

def test_generate_passphrase_different_separator(self):
pwd, pool = generate_passphrase(3, "_", False, False)
assert "_" in pwd or len(pwd.split("_")) == 3
Expand All @@ -84,6 +97,40 @@ def test_generate_passphrase_single_word(self):
pwd, pool = generate_passphrase(1, "-", False, False)
assert len(pwd.split("-")) == 1

@pytest.mark.parametrize(
("extra_args", "expected_number_choices"),
[([], 1), (["--add-number"], 100)],
)
def test_cli_passphrase_uses_secure_defaults_and_word_entropy(
self, monkeypatch, tmp_path, extra_args, expected_number_choices
):
config_dir = tmp_path / ".passgen"
monkeypatch.setattr(main_module, "CONFIG_DIR", config_dir)
monkeypatch.setattr(main_module, "HISTORY_FILE", config_dir / "history.json")
monkeypatch.setattr(main_module, "CONFIG_FILE", config_dir / "config.json")
monkeypatch.setattr(main_module, "WORDLIST_DIR", config_dir / "wordlists")
monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory())
monkeypatch.setattr(sys, "argv", ["passgen", "--passphrase", *extra_args])

observed = {}

def fake_generate(word_count, separator, capitalize, include_number):
observed["word_count"] = word_count
observed["include_number"] = include_number
return "alpha-beta-gamma-delta-epsilon-zeta", 7776

def fake_entropy(word_count, pool_size, number_choices=1):
observed["entropy_args"] = (word_count, pool_size, number_choices)
return 80.0

monkeypatch.setattr(main_module, "generate_passphrase", fake_generate)
monkeypatch.setattr(main_module, "calculate_passphrase_entropy", fake_entropy)

main_module.main()

assert observed["word_count"] == 6
assert observed["entropy_args"] == (6, 7776, expected_number_choices)


class TestEntropyCalculation:
def test_calculate_entropy_basic(self):
Expand Down
62 changes: 62 additions & 0 deletions tests/test_passphrases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import math
from pathlib import Path

import pytest

from passphrases import (
BUILTIN_WORDLIST,
BUILTIN_WORDLIST_PATH,
calculate_passphrase_entropy,
load_wordlist,
)


def test_bundled_eff_wordlist_has_expected_integrity():
assert BUILTIN_WORDLIST_PATH.name == "eff_large_wordlist.txt"
assert len(BUILTIN_WORDLIST) == 7776
assert len(set(BUILTIN_WORDLIST)) == 7776
assert BUILTIN_WORDLIST[0] == "abacus"
assert BUILTIN_WORDLIST[-1] == "zoom"


def test_bundled_wordlist_is_cached():
assert load_wordlist() is load_wordlist()


def test_load_wordlist_rejects_duplicates(tmp_path: Path):
path = tmp_path / "duplicate.txt"
path.write_text("alpha\nalpha\n")

with pytest.raises(ValueError, match="duplicate words"):
load_wordlist(path, expected_count=2)


def test_load_wordlist_rejects_unexpected_size(tmp_path: Path):
path = tmp_path / "short.txt"
path.write_text("alpha\nbeta\n")

with pytest.raises(ValueError, match="expected 3 words, found 2"):
load_wordlist(path, expected_count=3)


def test_passphrase_entropy_uses_word_count_not_character_count():
assert calculate_passphrase_entropy(6, 7776) == pytest.approx(
6 * math.log2(7776)
)


def test_random_number_suffix_adds_one_hundred_uniform_choices():
assert calculate_passphrase_entropy(6, 7776, number_choices=100) == pytest.approx(
(6 * math.log2(7776)) + math.log2(100)
)


@pytest.mark.parametrize(
("word_count", "pool_size", "number_choices"),
[(0, 7776, 1), (6, 0, 1), (6, 7776, 0)],
)
def test_passphrase_entropy_rejects_non_positive_inputs(
word_count, pool_size, number_choices
):
with pytest.raises(ValueError, match="must be positive"):
calculate_passphrase_entropy(word_count, pool_size, number_choices)
Loading
Loading