diff --git a/.cursor/rules/cli-write-safety.mdc b/.cursor/rules/cli-write-safety.mdc new file mode 100644 index 0000000..24e6f5b --- /dev/null +++ b/.cursor/rules/cli-write-safety.mdc @@ -0,0 +1,99 @@ +--- +description: Every CLI in this repo that writes or destroys must support --dry-run, confirm by default, and accept --yes +alwaysApply: true +--- + +# CLI Write Safety + +Applies to **every command-line tool produced in this repo**, whatever the language or +argument parser: Click groups, `argparse` scripts under `tools/`, Django management +commands, TypeScript `bin` entries, and shell scripts. + +## The three requirements + +Any command that writes remote or local state, deletes, rotates a credential, or is +otherwise not trivially reversible must have all three: + +1. **`--dry-run`** — runs the same code path, prints exactly what would change, exits 0, + and touches nothing. It is not a separate "plan" branch that can drift from the real + one; resolve the same targets and build the same payloads, then stop before the write. +2. **Confirmation by default** — print a summary of what is about to happen (counts, + target names, account/stack/environment), then prompt. Abort on anything but yes. +3. **`--yes`** (alias `-y`) — bypasses the prompt, and nothing else. It must never imply + or cancel `--dry-run`. + +## Use these exact flag names + +`--dry-run` and `--yes`. Do not invent `--apply`, `--submit`, `--commit`, `--force`, +`--no-confirm`, or `--for-real`. + +Opt-in-to-write flags look safe but are not: they make the *safe* path the one the +operator has to remember, and every tool picks a different word for it, so muscle memory +from one tool does nothing for the next. Prompting by default gives the same protection +without depending on recall. + +## Precedence and non-interactive runs + +- `--dry-run` wins if both flags are passed. +- When a **real write** would occur, never silently proceed without explicit consent. + Without a TTY, or in `--json` mode, fail and tell the operator to pass `--yes`. + `--dry-run` skips this check because nothing is written. + +## Read-only commands + +Must not prompt, and must not offer `--dry-run` — there is nothing to preview. + +## Where the flags live + +Put `--dry-run` on the group when it governs every subcommand, and `--yes` on each write +command. `keys delete` in `packages/python/ess-langsmith-client/src/ess_langsmith_client/tools/keys.py` +shows the confirmation half: it lists the keys it will delete, prompts by default, and +accepts `--yes`. It does not yet offer `--dry-run`, and its prompt goes to stdout; do not +copy those two gaps. The complete shape: + +```python +@click.option("--dry-run", is_flag=True, default=False, + help="Show planned changes without submitting them.") +... +@click.option("--yes", is_flag=True, default=False, help="Skip the confirmation prompt.") +``` + +with a single guard called by every write path: + +```python +def _confirm_or_abort(options, account, locations, *, skip: bool) -> None: + """Require explicit consent before writing credentials.""" + if skip or options.dry_run: + return + + # Real write only; --dry-run returned above. --json has no interactive + # prompt to fall back on, so make the caller opt in rather than silently + # skipping the guard. + if options.as_json: + raise click.UsageError("--json requires --yes, which confirms the write.") + + click.confirm(f"\nSet a new password for {account} in {len(locations)} location(s)?", + abort=True, err=True) +``` + +For interactive **real writes**, prompt on **stderr** (`err=True`) so piping stdout to +a file or `jq` still shows the question. Dry-run and `--yes` paths skip the prompt, so +`--dry-run --json` without `--yes` is valid. + +`@click.confirmation_option(prompt=...)` gives you `--yes` for free and satisfies +requirements 2 and 3, but you still owe `--dry-run`. + +For `argparse`, the equivalent is `--dry-run` / `--yes` store-true flags plus an explicit +`sys.stdin.isatty()` check before `input()` for real writes (not `--dry-run`); fail with +a message to pass `--yes` if non-interactive. + +## Tests (Click / Python) + +For Click-based Python CLIs, co-locate `test_*.py` per the repo's testing convention. +Two cases at minimum, using Click's `CliRunner`: + +- `--dry-run` performs no writes (assert the client/session mock was never called). +- `--yes` skips the prompt (assert the command succeeds with no stdin). + +TypeScript, shell, and Django commands must still prove the same three requirements, +using that language's test tooling and conventions. diff --git a/.cursor/rules/gitignore.mdc b/.cursor/rules/gitignore.mdc index d3d7f7e..eae1b89 100644 --- a/.cursor/rules/gitignore.mdc +++ b/.cursor/rules/gitignore.mdc @@ -53,5 +53,5 @@ curl https://raw.githubusercontent.com/github/gitignore/refs/heads/main/communit Ignore LangGraph artifacts. ```sh -curl https://raw.githubusercontent.com/mattnorris/gitignore/refs/heads/langchain/LangChain.gitignore >> .gitignore +curl https://raw.githubusercontent.com/github/gitignore/refs/heads/main/LangChain.gitignore >> .gitignore ``` diff --git a/.cursor/rules/pulumi.mdc b/.cursor/rules/pulumi.mdc index 1de0092..a15bddc 100644 --- a/.cursor/rules/pulumi.mdc +++ b/.cursor/rules/pulumi.mdc @@ -20,7 +20,7 @@ This creates a `.pulumi/` directory in the current package for state storage. ## Key Points -- State lives in `.pulumi/` at the package level (e.g., `tools/python/aws-artifact-repo/.pulumi/`) +- State lives in `.pulumi/` at the package level (e.g., `tools/python/langsmith-hosting/.pulumi/`) - Do NOT use `~/.pulumi/` (default) or workspace root for state - Each Pulumi project manages its own state independently - Use `uv run pulumi` to ensure correct Python environment @@ -28,7 +28,7 @@ This creates a `.pulumi/` directory in the current package for state storage. ## Example Workflow ```bash -cd tools/python/aws-artifact-repo +cd tools/python/langsmith-hosting uv run pulumi login file://. uv run pulumi stack init dev uv run pulumi config set aws:region us-east-1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59a886d..5e893fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: entry: uv run pylint --load-plugins=perflint language: system types: [python] - exclude: ^.*/(migrations|tests|__pycache__|\.venv|venv)/.* + exclude: ^.*/(migrations|tests|__pycache__|\.venv|venv)/.*|.*/test_[^/]*\.py pass_filenames: true args: ["--ignore=migrations,tests,__pycache__,.venv,venv"] stages: [pre-push] diff --git a/packages/python/ess-passwords/README.md b/packages/python/ess-passwords/README.md new file mode 100644 index 0000000..717ddbd --- /dev/null +++ b/packages/python/ess-passwords/README.md @@ -0,0 +1,107 @@ +# ess-passwords + +Readable passwords from a filtered EFF wordlist — the kind you can read +aloud on a call without wincing. + +```bash +$ ess-passwords generate +swept-staunch-circling-showdown-SCREEN +(63 bits of entropy from 6,131 candidate words) +``` + +The password goes to stdout and the entropy note to stderr, so it pipes +straight into a secret store: + +```bash +ess-passwords generate | uv run pulumi config set myproj:dbPassword --secret +``` + +From Python: + +```python +from ess_passwords import PasswordOptions, generate_password + +generated = generate_password(PasswordOptions(word_count=6, digit_count=4)) +print(generated.value) # VOCATION-paddle-move-bonding-overrate-scruffy-2810 +print(generated.entropy_bits) # 88.8 +``` + +## Installation + +From the workspace root: + +```bash +uv sync --all-packages +``` + +A plain `uv sync` will not put the `ess-passwords` command on `PATH`. + +## CLI + +### `ess-passwords generate` + +Prints one password per line. Read-only — it writes nothing, so there is +no `--dry-run` and no confirmation prompt. + +| Flag | Default | Description | +|---|---|---| +| `--words` | `5` | Number of words in the password | +| `--separator` | `-` | String joining the words | +| `--max-length` | none | Regenerate until the password fits in this many characters | +| `--capitalize-at` | random | Position of the upper-cased word, counting from 0 | +| `--digits` | `0` | Digits to append after the last word | +| `--count` / `-n` | `1` | Number of passwords to print | +| `--json` | off | Emit JSON: an object, or an array when `--count` is above 1 | + +## API + +### `generate_password(options=None) -> GeneratedPassword` + +Generates one password. Exactly one word is upper-cased. The result +carries no date, symbol, or other tail — append whatever the consuming +system's complexity rules need, and size `max_length` to leave room for +it. Raises `PasswordPolicyError` when the options cannot produce a +password. + +### `PasswordOptions` + +Frozen dataclass. Every field is optional. + +| Field | Default | Description | +|---|---|---| +| `word_count` | `5` | Number of words in the password | +| `separator` | `"-"` | String joining the words, and the digits when asked for | +| `max_length` | `None` | Reject and regenerate passwords longer than this | +| `capitalized_index` | `None` | Position of the one upper-cased word, from 0. `None` picks one at random per password | +| `digit_count` | `0` | Digits appended after the last word | + +### `GeneratedPassword` + +Frozen dataclass with `value`, `entropy_bits`, and `wordlist_size`. +`entropy_bits` counts the random word and digit choices only. Which word +is capitalised is not counted: it is worth just `log2(word_count)` +against a scheme an attacker already knows, and understating strength is +the safe direction. + +### `load_wordlist()` and `excluded_words()` + +The candidate words, and the profanity and sexual terms filtered out of +them. Entropy is derived from the filtered list, so exclusions are +reflected in reported strength automatically. `load_wordlist()` reads +and filters the EFF file once per process and returns the same immutable +tuple thereafter; word selection is per password, so this does not make +passwords repeat. If a crude word slips through, add it to +`_EXCLUDED_WORDS` in [`wordlist.py`](src/ess_passwords/wordlist.py) +rather than regenerating a password by hand. + +## Running tests + +```bash +uv run pytest packages/python/ess-passwords +``` + +## License + +Generation uses +[xkcdpass](https://github.com/redacted/XKCD-password-generator) +(BSD-3-Clause), which bundles the EFF long wordlist. diff --git a/packages/python/ess-passwords/pyproject.toml b/packages/python/ess-passwords/pyproject.toml new file mode 100644 index 0000000..df957ea --- /dev/null +++ b/packages/python/ess-passwords/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "ess-passwords" +version = "0.1.0" +description = "Readable password generation from a filtered EFF wordlist" +readme = "README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "click>=8.1", + "xkcdpass>=1.30", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[project.scripts] +ess-passwords = "ess_passwords.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +exclude = ["**/test_*.py"] + +[tool.hatch.build.targets.wheel] +packages = ["src/ess_passwords"] diff --git a/packages/python/ess-passwords/src/ess_passwords/__init__.py b/packages/python/ess-passwords/src/ess_passwords/__init__.py new file mode 100644 index 0000000..4a42bb4 --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/__init__.py @@ -0,0 +1,23 @@ +"""Readable password generation from a filtered EFF wordlist.""" + +from .exceptions import EssPasswordsError, PasswordPolicyError +from .password import ( + DEFAULT_SEPARATOR, + DEFAULT_WORD_COUNT, + GeneratedPassword, + PasswordOptions, + generate_password, +) +from .wordlist import excluded_words, load_wordlist + +__all__ = [ + "DEFAULT_SEPARATOR", + "DEFAULT_WORD_COUNT", + "EssPasswordsError", + "GeneratedPassword", + "PasswordOptions", + "PasswordPolicyError", + "excluded_words", + "generate_password", + "load_wordlist", +] diff --git a/packages/python/ess-passwords/src/ess_passwords/__main__.py b/packages/python/ess-passwords/src/ess_passwords/__main__.py new file mode 100644 index 0000000..063514c --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/__main__.py @@ -0,0 +1,134 @@ +"""CLI entry point for ess-passwords. + +Every command here is read-only: it reads the wordlist, prints a password, +and writes nothing anywhere. `.cursor/rules/cli-write-safety.mdc` therefore +forbids `--dry-run` and a confirmation prompt -- there is no state to +preview and nothing to consent to. Do not add them: generating a password +is not the same as rotating one, and a decorative `--dry-run` teaches +operators to distrust the real ones. +""" + +from __future__ import annotations + +import json + +import click + +from .exceptions import EssPasswordsError +from .password import ( + DEFAULT_SEPARATOR, + DEFAULT_WORD_COUNT, + GeneratedPassword, + PasswordOptions, + generate_password, +) + + +@click.group() +def main() -> None: + """Generate readable passwords from a filtered EFF wordlist.""" + + +@main.command("generate") +@click.option( + "--words", + default=DEFAULT_WORD_COUNT, + show_default=True, + help="Number of words in the password.", +) +@click.option( + "--separator", + default=DEFAULT_SEPARATOR, + show_default=True, + help="Separator between words.", +) +@click.option( + "--max-length", + type=int, + default=None, + help="Regenerate until the password fits within this many characters.", +) +@click.option( + "--capitalize-at", + type=int, + default=None, + help=( + "Position of the upper-cased word, counting from 0. " + "Omit to have it chosen at random." + ), +) +@click.option( + "--digits", + default=0, + show_default=True, + help="Digits to append after the last word.", +) +@click.option( + "--count", + "-n", + type=click.IntRange(min=1), + default=1, + show_default=True, + help="Number of passwords to print, one per line.", +) +@click.option( + "--json", + "as_json", + is_flag=True, + default=False, + help="Emit JSON instead of a bare password.", +) +def generate_command( # noqa: PLR0913 -- Click option surface; bundling hurts readability + words: int, + separator: str, + max_length: int | None, + capitalize_at: int | None, + digits: int, + count: int, + as_json: bool, +) -> None: + """Print a password such as `marble-TUNDRA-lantern-copper-bison`. + + The password goes to stdout and its entropy to stderr, so the output + pipes straight into a secret store without a stray note tagging along. + """ + options = PasswordOptions( + word_count=words, + separator=separator, + max_length=max_length, + capitalized_index=capitalize_at, + digit_count=digits, + ) + + try: + generated = [generate_password(options) for _ in range(count)] + except EssPasswordsError as exc: + raise click.ClickException(str(exc)) from exc + + if as_json: + payload = [_as_dict(item) for item in generated] + click.echo(json.dumps(payload[0] if count == 1 else payload)) + return + + for item in generated: + click.echo(item.value) + + first = generated[0] + click.echo( + f"({first.entropy_bits:.0f} bits of entropy from " + f"{first.wordlist_size:,} candidate words)", + err=True, + ) + + +def _as_dict(generated: GeneratedPassword) -> dict[str, object]: + """The JSON shape for one generated password.""" + return { + "password": generated.value, + "entropy_bits": round(generated.entropy_bits, 1), + "wordlist_size": generated.wordlist_size, + } + + +if __name__ == "__main__": + main() diff --git a/packages/python/ess-passwords/src/ess_passwords/exceptions.py b/packages/python/ess-passwords/src/ess_passwords/exceptions.py new file mode 100644 index 0000000..0221a35 --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/exceptions.py @@ -0,0 +1,11 @@ +"""Domain exceptions for password generation.""" + +from __future__ import annotations + + +class EssPasswordsError(Exception): + """Base class for every error raised by this package.""" + + +class PasswordPolicyError(EssPasswordsError): + """The requested password options cannot produce a valid password.""" diff --git a/packages/python/ess-passwords/src/ess_passwords/password.py b/packages/python/ess-passwords/src/ess_passwords/password.py new file mode 100644 index 0000000..7d82660 --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/password.py @@ -0,0 +1,182 @@ +"""Readable password generation. + +Thin wrapper around `xkcdpass`, which draws from +:class:`random.SystemRandom`. The candidate words come from +:mod:`ess_passwords.wordlist`, which filters the EFF long list. +""" + +from __future__ import annotations + +import math +import secrets +from dataclasses import dataclass + +from xkcdpass import xkcd_password + +from .exceptions import PasswordPolicyError +from .wordlist import load_wordlist + +DEFAULT_WORD_COUNT = 5 +DEFAULT_SEPARATOR = "-" + +_DIGITS = "0123456789" + +# Give up rather than loop forever when max_length is set too low. +_MAX_GENERATION_ATTEMPTS = 100 + +# Shortest possible word, used only to explain an impossible max_length. +_MIN_WORD_LENGTH = 4 + + +@dataclass(frozen=True) +class PasswordOptions: + """Knobs for password generation. + + Attributes: + word_count: Number of words in the password. + separator: String joining the words, and the digits when asked for. + max_length: Reject and regenerate passwords longer than this. + capitalized_index: Position of the one upper-cased word, counting + from 0. Defaults to a position chosen at random. + digit_count: Digits appended after the last word. Defaults to none. + """ + + word_count: int = DEFAULT_WORD_COUNT + separator: str = DEFAULT_SEPARATOR + max_length: int | None = None + capitalized_index: int | None = None + digit_count: int = 0 + + +@dataclass(frozen=True) +class GeneratedPassword: + """A generated password and the strength of the scheme behind it. + + Attributes: + value: The password itself. + entropy_bits: Entropy contributed by the random word and digit + choices. Which word is capitalised is not counted: it is worth + only log2(word_count) against a scheme an attacker already + knows, and understating strength is the safe direction. + wordlist_size: Number of candidate words after length filtering. + """ + + value: str + entropy_bits: float + wordlist_size: int + + def __str__(self) -> str: + return self.value + + +def _require_valid(options: PasswordOptions) -> None: + """Reject options that can never produce a password.""" + if options.word_count < 1: + msg = f"word_count must be at least 1, got {options.word_count}." + raise PasswordPolicyError(msg) + + if options.digit_count < 0: + msg = f"digit_count cannot be negative, got {options.digit_count}." + raise PasswordPolicyError(msg) + + if options.max_length is not None and options.max_length < 1: + msg = f"max_length must be at least 1, got {options.max_length}." + raise PasswordPolicyError(msg) + + index = options.capitalized_index + if index is not None and not 0 <= index < options.word_count: + msg = ( + f"capitalized_index must be between 0 and {options.word_count - 1} " + f"for {options.word_count} words, got {index}." + ) + raise PasswordPolicyError(msg) + + if options.max_length is not None and options.max_length < _shortest_possible( + options + ): + msg = _impossible_length_message(options) + raise PasswordPolicyError(msg) + + +def _compose(words: list[str], options: PasswordOptions) -> str: + """Join the words with one upper-cased, then append any digits.""" + capitalized = options.capitalized_index + if capitalized is None: + capitalized = secrets.randbelow(options.word_count) + + parts = [ + word.upper() if position == capitalized else word.lower() + for position, word in enumerate(words) + ] + if options.digit_count: + parts.append( + "".join(secrets.choice(_DIGITS) for _ in range(options.digit_count)) + ) + return options.separator.join(parts) + + +def _shortest_possible(options: PasswordOptions) -> int: + """Roughly the shortest password these options could produce.""" + groups = options.word_count + (1 if options.digit_count else 0) + return ( + options.word_count * _MIN_WORD_LENGTH + + options.digit_count + + (groups - 1) * len(options.separator) + ) + + +def _impossible_length_message(options: PasswordOptions) -> str: + """Why these options can never fit inside `max_length`.""" + needed = _shortest_possible(options) + return ( + f"Could not generate a password of at most {options.max_length} characters " + f"with {options.word_count} words (needs roughly {needed}). " + f"Reduce the word count or raise the length limit." + ) + + +def generate_password(options: PasswordOptions | None = None) -> GeneratedPassword: + """Generate a readable password such as `marble-TUNDRA-lantern-copper-bison`. + + Exactly one word is upper-cased. The result carries no date, symbol, + or other tail -- append whatever the consuming system's complexity + rules require, and size `max_length` to leave room for it. + + Args: + options: Generation knobs. Defaults to five words joined by + hyphens, one of them capitalised at random, no digits, and no + length ceiling. + + Returns: + The password plus the entropy of the scheme that produced it. + + Raises: + PasswordPolicyError: The options cannot produce a password -- + `word_count` or `max_length` below 1, `capitalized_index` + outside the words, or a `max_length` the word count cannot fit. + """ + opts = options or PasswordOptions() + _require_valid(opts) + + wordlist = load_wordlist() + entropy_bits = opts.word_count * math.log2(len(wordlist)) + if opts.digit_count: + entropy_bits += opts.digit_count * math.log2(len(_DIGITS)) + + for _ in range(_MAX_GENERATION_ATTEMPTS): + words = xkcd_password.generate_xkcdpassword( + wordlist, + numwords=opts.word_count, + delimiter="\n", + case="lower", + ).split("\n") + candidate = _compose(words, opts) + if opts.max_length is None or len(candidate) <= opts.max_length: + return GeneratedPassword( + value=candidate, + entropy_bits=entropy_bits, + wordlist_size=len(wordlist), + ) + + msg = _impossible_length_message(opts) + raise PasswordPolicyError(msg) diff --git a/packages/python/ess-passwords/src/ess_passwords/test_main.py b/packages/python/ess-passwords/src/ess_passwords/test_main.py new file mode 100644 index 0000000..5f153c0 --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/test_main.py @@ -0,0 +1,101 @@ +"""Tests for the ess-passwords CLI.""" + +from __future__ import annotations + +import json +import re + +from click.testing import CliRunner + +from ess_passwords.__main__ import main + + +def _lines(output: str) -> list[str]: + return [line for line in output.splitlines() if line] + + +def test_generate_prints_one_password() -> None: + result = CliRunner().invoke(main, ["generate"]) + + assert result.exit_code == 0 + assert re.fullmatch(r"[a-zA-Z]+(-[a-zA-Z]+){4}", _lines(result.stdout)[0]) + + +def test_entropy_note_goes_to_stderr_only() -> None: + result = CliRunner().invoke(main, ["generate"]) + + # The password must pipe cleanly into a secret store, so stdout + # carries nothing but the password itself. + assert len(_lines(result.stdout)) == 1 + assert "entropy" not in result.stdout + assert "bits of entropy" in result.stderr + + +def test_json_carries_the_password_and_its_strength() -> None: + result = CliRunner().invoke(main, ["generate", "--json"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload.keys() == {"password", "entropy_bits", "wordlist_size"} + assert payload["entropy_bits"] > 50 + + +def test_json_with_count_is_a_list() -> None: + result = CliRunner().invoke(main, ["generate", "--json", "--count", "3"]) + payload = json.loads(result.stdout) + + assert isinstance(payload, list) + assert len({item["password"] for item in payload}) == 3 + + +def test_count_prints_that_many_distinct_passwords() -> None: + result = CliRunner().invoke(main, ["generate", "--count", "3"]) + + assert result.exit_code == 0 + assert len(set(_lines(result.stdout))) == 3 + + +def test_words_separator_and_digits_are_honoured() -> None: + result = CliRunner().invoke( + main, + ["generate", "--words", "3", "--separator", ".", "--digits", "4"], + ) + *words, digits = _lines(result.stdout)[0].split(".") + + assert len(words) == 3 + assert re.fullmatch(r"\d{4}", digits) + + +def test_capitalize_at_selects_the_position() -> None: + result = CliRunner().invoke(main, ["generate", "--capitalize-at", "0"]) + + assert re.fullmatch(r"[A-Z]+(-[a-z]+){4}", _lines(result.stdout)[0]) + + +def test_out_of_range_capitalize_at_reports_the_policy_error() -> None: + result = CliRunner().invoke( + main, + ["generate", "--words", "3", "--capitalize-at", "3"], + ) + + assert result.exit_code != 0 + assert "capitalized_index" in result.stderr + + +def test_impossible_max_length_reports_the_policy_error() -> None: + result = CliRunner().invoke( + main, + ["generate", "--words", "8", "--max-length", "20"], + ) + + assert result.exit_code != 0 + assert "Reduce the word count" in result.stderr + + +def test_generate_offers_no_dry_run() -> None: + # Read-only per cli-write-safety.mdc: there is nothing to preview, + # so the flag must not exist rather than being a silent no-op. + result = CliRunner().invoke(main, ["generate", "--dry-run"]) + + assert result.exit_code != 0 + assert "no such option" in result.stderr.lower() diff --git a/packages/python/ess-passwords/src/ess_passwords/test_password.py b/packages/python/ess-passwords/src/ess_passwords/test_password.py new file mode 100644 index 0000000..698297a --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/test_password.py @@ -0,0 +1,217 @@ +"""Tests for readable password generation.""" + +from __future__ import annotations + +import re + +import pytest +from xkcdpass import xkcd_password + +from ess_passwords.exceptions import PasswordPolicyError +from ess_passwords.password import ( + DEFAULT_SEPARATOR, + DEFAULT_WORD_COUNT, + PasswordOptions, + generate_password, +) +from ess_passwords.wordlist import excluded_words, load_wordlist + + +def _words(value: str, separator: str = DEFAULT_SEPARATOR) -> list[str]: + return value.split(separator) + + +def _capitalized_index(value: str, separator: str = DEFAULT_SEPARATOR) -> int: + return next( + position + for position, word in enumerate(_words(value, separator)) + if word.isupper() + ) + + +def test_default_shape() -> None: + result = generate_password(PasswordOptions(capitalized_index=0)) + + assert re.fullmatch(r"[A-Z]+(-[a-z]+){4}", result.value) + + +def test_one_word_is_upper_and_the_rest_are_lower() -> None: + words = _words(generate_password().value) + upper = [word for word in words if word.isupper()] + + assert len(upper) == 1 + assert len(words) == DEFAULT_WORD_COUNT + assert all(word.islower() for word in words if not word.isupper()) + + +def test_word_count_is_configurable() -> None: + result = generate_password(PasswordOptions(word_count=7)) + + assert len(_words(result.value)) == 7 + + +def test_separator_is_configurable() -> None: + result = generate_password(PasswordOptions(separator=".")) + + assert len(_words(result.value, ".")) == DEFAULT_WORD_COUNT + assert "-" not in result.value + + +def test_capitalized_index_selects_the_position() -> None: + for position in range(DEFAULT_WORD_COUNT): + result = generate_password(PasswordOptions(capitalized_index=position)) + + assert _capitalized_index(result.value) == position + + +def test_capitalized_position_varies_by_default() -> None: + # The position is drawn per password, so over many draws it must not + # land on the same word every time. + positions = {_capitalized_index(generate_password().value) for _ in range(200)} + + assert len(positions) > 1 + + +def test_out_of_range_capitalized_index_raises() -> None: + with pytest.raises(PasswordPolicyError, match="capitalized_index"): + generate_password(PasswordOptions(word_count=3, capitalized_index=3)) + + +def test_no_digits_by_default() -> None: + assert not any(char.isdigit() for char in generate_password().value) + + +def test_digits_are_appended_after_the_words() -> None: + result = generate_password(PasswordOptions(digit_count=4)) + *words, digits = _words(result.value) + + assert len(words) == DEFAULT_WORD_COUNT + assert re.fullmatch(r"\d{4}", digits) + + +def test_digits_raise_entropy() -> None: + without = generate_password().entropy_bits + with_digits = generate_password(PasswordOptions(digit_count=4)).entropy_bits + + # Four decimal digits are worth 4 * log2(10), a little over 13 bits. + assert with_digits > without + 13 + + +def test_negative_digit_count_raises() -> None: + with pytest.raises(PasswordPolicyError, match="digit_count"): + generate_password(PasswordOptions(digit_count=-1)) + + +def test_passwords_are_not_repeated() -> None: + values = {generate_password().value for _ in range(20)} + + assert len(values) == 20 + + +def test_reports_entropy_from_actual_wordlist() -> None: + result = generate_password() + + assert result.wordlist_size > 1000 + # Five words from a wordlist of a few thousand clears 50 bits easily. + assert result.entropy_bits > 50 + + +def test_max_length_is_respected() -> None: + result = generate_password(PasswordOptions(word_count=3, max_length=30)) + + assert len(result.value) <= 30 + + +def test_impossible_max_length_raises() -> None: + with pytest.raises(PasswordPolicyError, match="Reduce the word count"): + generate_password(PasswordOptions(word_count=8, max_length=20)) + + +def test_impossible_max_length_is_rejected_before_generating( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Eight words need roughly 39 characters, so no draw can fit 20. The + # bound is known up front, so nothing should be composed at all. + def unreachable(*args: object, **kwargs: object) -> str: + raise AssertionError("no password should have been composed") + + monkeypatch.setattr(xkcd_password, "generate_xkcdpassword", unreachable) + + with pytest.raises(PasswordPolicyError, match="Reduce the word count"): + generate_password(PasswordOptions(word_count=8, max_length=20)) + + +def test_zero_words_raises() -> None: + with pytest.raises(PasswordPolicyError, match="at least 1"): + generate_password(PasswordOptions(word_count=0)) + + +def test_zero_max_length_raises() -> None: + with pytest.raises(PasswordPolicyError, match="max_length must be at least 1"): + generate_password(PasswordOptions(max_length=0)) + + +def test_negative_max_length_raises() -> None: + # Rejected up front rather than after a hundred doomed attempts, + # whose message would quote a nonsensical "at most -1 characters". + with pytest.raises(PasswordPolicyError, match="max_length must be at least 1"): + generate_password(PasswordOptions(max_length=-1)) + + +def test_wordlist_is_loaded_once_across_generations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Every generate_password() asks for the wordlist, so reading and + # filtering the EFF file has to happen on the first call only. + loads = 0 + read_and_filter = xkcd_password.generate_wordlist + + def counting_generate_wordlist(**kwargs: object) -> list[str]: + nonlocal loads + loads += 1 + return read_and_filter(**kwargs) + + monkeypatch.setattr(xkcd_password, "generate_wordlist", counting_generate_wordlist) + load_wordlist.cache_clear() + + values = {generate_password().value for _ in range(5)} + + assert loads == 1 + assert load_wordlist.cache_info().hits == 4 + # The cache holds the candidate words, not a password: selection is + # still per call, so the results must all differ. + assert len(values) == 5 + + +def test_wordlist_excludes_unsuitable_words() -> None: + words = {word.lower() for word in load_wordlist()} + + assert not words & excluded_words() + + +def test_badass_is_gone() -> None: + # The word that prompted the filter. Named explicitly so a wordlist + # or upstream change that reintroduces it fails loudly. + assert "badass" not in {word.lower() for word in load_wordlist()} + + +def test_generated_passwords_avoid_excluded_words() -> None: + excluded = excluded_words() + for _ in range(200): + words = {word.lower() for word in _words(generate_password().value)} + + assert not words & excluded + + +def test_reported_wordlist_size_matches_the_filtered_list() -> None: + result = generate_password() + + # Entropy is derived from this number, so it must describe the list + # actually drawn from rather than the unfiltered one. + assert result.wordlist_size == len(load_wordlist()) + + +def test_filtering_leaves_entropy_intact() -> None: + # Removing a handful of words from several thousand must not + # meaningfully weaken the scheme. + assert generate_password().entropy_bits > 60 diff --git a/packages/python/ess-passwords/src/ess_passwords/wordlist.py b/packages/python/ess-passwords/src/ess_passwords/wordlist.py new file mode 100644 index 0000000..63dbf76 --- /dev/null +++ b/packages/python/ess-passwords/src/ess_passwords/wordlist.py @@ -0,0 +1,72 @@ +"""The word source for generated passwords, with unsuitable words removed. + +Generated passwords get pasted into tickets, chat, and runbooks, so they +have to read professionally. The EFF long list is mostly clean but not +entirely -- it contains `badass`, which is how this filter came to exist. + +`xkcdpass` cannot do this for us. Its only filters are `min_length`, +`max_length`, and `valid_chars` (a per-character pattern, not a word +filter), and no bundled list avoids the problem: `eff-special` also +contains `badass`, `eff-short` contains `grope`, and both are small +enough to cost around 12 bits of entropy compared with `eff-long`. +Filtering the long list ourselves keeps the strength and fixes the words. +""" + +from __future__ import annotations + +from functools import lru_cache + +from xkcdpass import xkcd_password + +WORDFILE = "eff-long" + +# Readability bounds -- long enough to be distinct, short enough to type. +_MIN_WORD_LENGTH = 4 +_MAX_WORD_LENGTH = 8 + +# Profanity and sexual terms present in the length-filtered EFF long +# list. Compared whole-word and case-insensitively: a substring test +# would take `class`, `grass`, and `assess` with it. +# +# Deliberately narrow. The same list also holds bleak-but-inoffensive +# words (`treason`, `carnage`, `obituary` and about 55 others); add them +# here if passwords should avoid a grim tone as well, which costs well +# under a tenth of a bit. +_EXCLUDED_WORDS = frozenset( + { + "badass", + "gigolo", + "grope", + "impure", + "rectal", + "seduce", + } +) + + +@lru_cache(maxsize=1) +def load_wordlist() -> tuple[str, ...]: + """Candidate words for password generation. + + Reading and filtering the EFF list costs the same every time, and a + password is generated per call, so the result is cached for the + lifetime of the process. Word selection happens in the caller, so + caching here does not make passwords repeat. The tuple is immutable + because every caller is handed the same object. + + Returns: + The EFF long list, filtered to readable lengths, with unsuitable + words removed. Callers derive entropy from its length, so the + exclusions are reflected in the reported strength automatically. + """ + words = xkcd_password.generate_wordlist( + wordfile=xkcd_password.locate_wordfile(WORDFILE), + min_length=_MIN_WORD_LENGTH, + max_length=_MAX_WORD_LENGTH, + ) + return tuple(word for word in words if word.lower() not in _EXCLUDED_WORDS) + + +def excluded_words() -> frozenset[str]: + """The words this module refuses to put in a password.""" + return _EXCLUDED_WORDS diff --git a/pyproject.toml b/pyproject.toml index 74dfc41..e68fbe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ members = [ "packages/python/ess-dirs", "packages/python/ess-langsmith-client", "packages/python/ess-outlook", + "packages/python/ess-passwords", "packages/python/ess-service-now-incident", "packages/python/ess-webex", "packages/python/langsmith-network", @@ -41,6 +42,11 @@ exclude = ["**/migrations/**", "**/tmp/**"] [tool.ruff.lint] select = ["E", "F", "B", "PERF", "C", "I", "N", "PL"] +[tool.ruff.lint.per-file-ignores] +# ess-passwords tests: inline word counts, length caps, and entropy floors are +# the values under test; naming them would just indirect the assertion. +"packages/python/ess-passwords/src/ess_passwords/test_*.py" = ["PLR2004"] + [tool.bandit] exclude_dirs = [".venv", "venv", "node_modules", "__pycache__", ".git"] exclude = ["*test*.py"] diff --git a/uv.lock b/uv.lock index ab111e8..bf98f1f 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "ess-langsmith-client", "ess-messages", "ess-outlook", + "ess-passwords", "ess-service-now-incident", "ess-webex", "essentials", @@ -614,6 +615,29 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.0" }] +[[package]] +name = "ess-passwords" +version = "0.1.0" +source = { editable = "packages/python/ess-passwords" } +dependencies = [ + { name = "click" }, + { name = "xkcdpass" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1" }, + { name = "xkcdpass", specifier = ">=1.30" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "ess-service-now-incident" version = "0.1.0" @@ -2213,6 +2237,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/04/86ab8349a02b43340eae36f275c94712f178a55a9b9c4842864dc74becf6/wxc_sdk-1.34.0-py3-none-any.whl", hash = "sha256:afb827965665412546663ddc16e5f5935607565b3d7c46820ada5a1b5ada2697", size = 796634, upload-time = "2026-04-22T17:49:09.161Z" }, ] +[[package]] +name = "xkcdpass" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/98/bdd7df66d995eab38887a8eb0afb023750b0c590eb7d8545a7b722f683ef/xkcdpass-1.30.0.tar.gz", hash = "sha256:8a3a6b60255da40d0e5c812458280278c82d2c1cb90e48afbd6777dbbf8795c3", size = 2763380, upload-time = "2026-01-11T16:09:15.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/be/ea93adc1b4597b62c236d61dc6cf0e26ca8a729cb5afae4dc5acc5b33fa8/xkcdpass-1.30.0-py3-none-any.whl", hash = "sha256:3653a4a1e13de230808bcaf11f8c04207a5d3df8e2f7e1de698e11c262b5b797", size = 2746372, upload-time = "2026-01-12T14:48:30.627Z" }, +] + [[package]] name = "xxhash" version = "4.0.1"