From 18c991181a81e2ea50b0d6175afef6e68c1b180e Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Thu, 13 Nov 2025 13:08:49 +0000 Subject: [PATCH] chore: initial commit with existing codebase --- AGENTS.md | 25 + CLAUDE.md | 104 ++ __pycache__/salt.cpython-312.pyc | Bin 0 -> 5031 bytes .../2025-11-13-multi-algorithm-support.md | 1093 +++++++++++++++++ requirements.txt | 3 + salt.py | 111 ++ salt2.py | 57 + .../test_cli.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 2904 bytes .../test_hashing.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 5290 bytes tests/test_cli.py | 11 + tests/test_hashing.py | 20 + 11 files changed, 1424 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 __pycache__/salt.cpython-312.pyc create mode 100644 docs/plans/2025-11-13-multi-algorithm-support.md create mode 100644 requirements.txt create mode 100644 salt.py create mode 100644 salt2.py create mode 100644 tests/__pycache__/test_cli.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/__pycache__/test_hashing.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/test_cli.py create mode 100644 tests/test_hashing.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dbf0eac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The repo is intentionally small: `salt.py` is a reference implementation that demonstrates secure PBKDF2 hashing, while `salt2.py` wraps the same logic in a CLI with verify mode. Keep new helpers alongside these modules only if they remain single-file utilities; otherwise scaffold a package under `salt/` and mirror tests in `tests/`. Generate derived assets (sample salts, fixtures) in `artifacts/` and never commit secrets or real user data. + +## Build, Test, and Development Commands +Run quick manual checks directly: +```bash +python3 salt.py "MySecret" # prints salt + hash +python3 salt2.py verify # exit 0 on success +``` +Add dependencies to `requirements.txt` if you introduce new libraries. For automated validation add pytest and expose a default target: +```bash +python3 -m pytest # runs unit tests +python3 -m pytest -k verify --maxfail=1 -vv +``` + +## Coding Style & Naming Conventions +Target Python 3.11+, 4-space indentation, and type-annotate public functions. Use descriptive, lowercase_with_underscores names for variables and helper functions; reserve CapWords for classes if you introduce them. Favor stdlib modules (hashlib, secrets, base64) before external choices. Keep scripts executable (`#!/usr/bin/env python3`) and guard entry points with `if __name__ == "__main__":`. + +## Testing Guidelines +Pin tests under `tests/test_.py` and mirror module names (e.g., `tests/test_hashing.py`). Cover both happy paths and failure handling (invalid base64, mismatched hashes). Include property-style assertions such as round-tripping `hash_password` output into `verify_password`. When adding algorithms, require regression tests with representative salts and ensure coverage stays above 90% for the hashing utilities. + +## Commit & Pull Request Guidelines +Use short, imperative subject lines (e.g., `feat: add argon2 hashing option`) and describe motivation plus key changes in the body. Reference related issues with `Fixes #ID` when applicable. Open PRs only after tests pass, include reproduction or CLI output for security changes, and add screenshots or logs if the behavior affects tooling UX. Keep PRs focused; split large refactors into reviewable chunks. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3677661 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Python PBKDF2 password hashing utility with two implementations: +- `salt.py`: Reference implementation with CLI that supports both `hash` and `verify` subcommands, plus a shortcut where running `python3 salt.py ` automatically invokes the hash command +- `salt2.py`: Alternative CLI that imports from `salt.py` and requires explicit subcommands (`generate` and `verify`) + +Both implementations use PBKDF2-HMAC-SHA256 with configurable iteration counts (default: 200,000, overridable via `PBKDF2_ITERATIONS` env var). + +## Common Commands + +### Running the CLI +```bash +# Hash a password (salt.py supports shortcut syntax) +python3 salt.py "MyPassword" +python3 salt.py hash "MyPassword" + +# Verify a password +python3 salt.py verify + +# Alternative CLI (requires explicit subcommands) +python3 salt2.py generate "MyPassword" +python3 salt2.py verify +``` + +### Testing +```bash +# Run all tests +python3 -m pytest + +# Run specific tests with verbose output +python3 -m pytest -k verify --maxfail=1 -vv + +# Run tests for a specific module +python3 -m pytest tests/test_hashing.py +``` + +### Dependencies +Dependencies are listed in `requirements.txt`. Install with: +```bash +pip install -r requirements.txt +``` + +## Code Architecture + +### Module Structure +- **salt.py**: Core module containing `hash_password()` and `verify_password()` functions, plus an integrated CLI via `main()` +- **salt2.py**: Wrapper CLI that imports from `salt.py` and provides an alternative command structure +- **tests/**: Test suite mirroring the module structure + - `test_hashing.py`: Tests for core hashing/verification functions + - `test_cli.py`: Tests for CLI behavior (specifically `salt.py`'s shortcut syntax) + +### Key Functions + +**`hash_password(password, *, iterations=None, salt_bytes=16) -> tuple[str, str]`** +- Generates a cryptographically secure random salt +- Derives a hash using PBKDF2-HMAC-SHA256 +- Returns base64-encoded (salt, hash) tuple + +**`verify_password(password, salt_b64, hash_b64, *, iterations=None) -> bool`** +- Validates a password against stored salt/hash +- Uses timing-safe comparison via `hmac.compare_digest()` +- Returns `False` for invalid base64 without raising exceptions + +### CLI Argument Handling +The `salt.py` module includes `_normalize_args()` which allows shortcut syntax: if the first argument is not a subcommand (`hash`/`verify`) and not a flag, it's automatically prefixed with `hash`. This is tested in `tests/test_cli.py:test_main_supports_hash_shortcut()`. + +## Development Guidelines + +### Python Version and Style +- Target Python 3.11+ +- Use type annotations on public functions +- 4-space indentation +- Follow `lowercase_with_underscores` naming for functions and variables + +### Security Practices +- Always use `os.urandom()` for salt generation (never predictable values) +- Use `hmac.compare_digest()` for hash comparison to prevent timing attacks +- Validate base64 input with `validate=True` parameter +- Handle encoding errors gracefully by returning `False` rather than raising exceptions + +### Testing Requirements +- Place tests in `tests/test_.py` matching the module name +- Cover happy paths and error handling (invalid base64, wrong passwords) +- Include round-trip tests: hash → verify with correct/incorrect passwords +- Maintain >90% coverage for hashing utilities +- When adding new hash algorithms, include regression tests with known salt/hash pairs + +### Configuration +- Iteration count defaults to 200,000 but respects `PBKDF2_ITERATIONS` environment variable +- Salt size defaults to 16 bytes (configurable via `salt_bytes` parameter) + +## Commit Guidelines + +Use imperative subject lines with conventional commit prefixes: +- `feat:` for new features +- `fix:` for bug fixes +- `test:` for test additions/changes +- `refactor:` for code restructuring + +Reference issues with `Fixes #ID` when applicable. PRs should only be opened after tests pass. diff --git a/__pycache__/salt.cpython-312.pyc b/__pycache__/salt.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bde62808d85955613cda831eda00d9c807d5c84b GIT binary patch literal 5031 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 **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend the password hashing utility to support multiple algorithms (PBKDF2, Argon2, bcrypt) with backward compatibility. + +**Architecture:** Create an algorithm registry pattern with pluggable hashers. Each algorithm implements a common interface. The CLI and core functions accept an algorithm parameter. Hash outputs include algorithm identifiers for verification. Maintain 100% backward compatibility with existing PBKDF2 hashes. + +**Tech Stack:** Python 3.11+, hashlib (PBKDF2), argon2-cffi (Argon2), bcrypt (bcrypt), pytest + +--- + +## Task 1: Add Dependencies + +**Files:** +- Modify: `requirements.txt` + +**Step 1: Add algorithm dependencies to requirements.txt** + +Add the following lines to `requirements.txt`: + +``` +argon2-cffi>=23.1.0 +bcrypt>=4.1.0 +``` + +**Step 2: Install dependencies** + +Run: `pip install -r requirements.txt` +Expected: Successfully installs argon2-cffi and bcrypt packages + +**Step 3: Commit dependency changes** + +```bash +git add requirements.txt +git commit -m "feat: add argon2-cffi and bcrypt dependencies" +``` + +--- + +## Task 2: Create Algorithm Interface + +**Files:** +- Create: `salt/algorithms.py` +- Create: `tests/test_algorithms.py` + +**Step 1: Write failing test for algorithm interface** + +Create `tests/test_algorithms.py`: + +```python +import pytest + +from salt.algorithms import Algorithm, get_algorithm + + +def test_algorithm_has_required_methods(): + """Verify Algorithm protocol defines required methods.""" + algo = get_algorithm("pbkdf2") + assert hasattr(algo, "hash") + assert hasattr(algo, "verify") + assert hasattr(algo, "identifier") + + +def test_get_algorithm_returns_pbkdf2(): + """Verify default algorithm is PBKDF2.""" + algo = get_algorithm("pbkdf2") + assert algo.identifier == "pbkdf2" + + +def test_get_algorithm_unknown_raises_error(): + """Verify unknown algorithm raises ValueError.""" + with pytest.raises(ValueError, match="Unknown algorithm"): + get_algorithm("unknown") +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_algorithms.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'salt.algorithms'" + +**Step 3: Create algorithm interface** + +Create `salt/algorithms.py`: + +```python +"""Algorithm interface and registry for password hashing.""" + +from __future__ import annotations + +from typing import Protocol + + +class Algorithm(Protocol): + """Protocol defining the interface for password hashing algorithms.""" + + identifier: str + + def hash(self, password: str, **kwargs) -> tuple[str, str]: + """Hash a password and return (salt_b64, hash_b64).""" + ... + + def verify(self, password: str, salt_b64: str, hash_b64: str, **kwargs) -> bool: + """Verify a password against stored salt and hash.""" + ... + + +# Algorithm registry +_ALGORITHMS: dict[str, Algorithm] = {} + + +def register_algorithm(algo: Algorithm) -> None: + """Register an algorithm in the global registry.""" + _ALGORITHMS[algo.identifier] = algo + + +def get_algorithm(name: str) -> Algorithm: + """Get an algorithm by name from the registry.""" + if name not in _ALGORITHMS: + raise ValueError(f"Unknown algorithm: {name}") + return _ALGORITHMS[name] + + +def list_algorithms() -> list[str]: + """Return list of registered algorithm identifiers.""" + return sorted(_ALGORITHMS.keys()) +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_algorithms.py::test_get_algorithm_unknown_raises_error -v` +Expected: PASS + +**Step 5: Commit algorithm interface** + +```bash +git add salt/algorithms.py tests/test_algorithms.py +git commit -m "feat: add algorithm interface and registry" +``` + +--- + +## Task 3: Implement PBKDF2 Algorithm + +**Files:** +- Create: `salt/pbkdf2_algorithm.py` +- Modify: `tests/test_algorithms.py` + +**Step 1: Write failing test for PBKDF2 algorithm** + +Add to `tests/test_algorithms.py`: + +```python +def test_pbkdf2_algorithm_hash_round_trip(): + """Verify PBKDF2 algorithm can hash and verify passwords.""" + algo = get_algorithm("pbkdf2") + salt, hashed = algo.hash("test password") + assert algo.verify("test password", salt, hashed) + assert not algo.verify("wrong password", salt, hashed) + + +def test_pbkdf2_algorithm_respects_iterations(): + """Verify PBKDF2 algorithm respects custom iterations.""" + algo = get_algorithm("pbkdf2") + salt1, hash1 = algo.hash("test", iterations=100000) + salt2, hash2 = algo.hash("test", iterations=200000) + # Different iterations should produce different hashes even with same password + assert hash1 != hash2 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_algorithms.py::test_pbkdf2_algorithm_hash_round_trip -v` +Expected: FAIL with "ValueError: Unknown algorithm: pbkdf2" + +**Step 3: Implement PBKDF2 algorithm** + +Create `salt/pbkdf2_algorithm.py`: + +```python +"""PBKDF2 algorithm implementation.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import os + +DEFAULT_SALT_BYTES = 16 +DEFAULT_ITERATIONS = int(os.environ.get("PBKDF2_ITERATIONS", "200000")) + + +class PBKDF2Algorithm: + """PBKDF2-HMAC-SHA256 password hashing algorithm.""" + + identifier = "pbkdf2" + + def hash( + self, + password: str, + *, + iterations: int | None = None, + salt_bytes: int = DEFAULT_SALT_BYTES, + ) -> tuple[str, str]: + """Hash a password using PBKDF2-HMAC-SHA256.""" + iterations = iterations or DEFAULT_ITERATIONS + salt = os.urandom(salt_bytes) + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return ( + base64.b64encode(salt).decode("utf-8"), + base64.b64encode(derived).decode("utf-8"), + ) + + def verify( + self, + password: str, + salt_b64: str, + hash_b64: str, + *, + iterations: int | None = None, + ) -> bool: + """Verify a password against PBKDF2 salt and hash.""" + iterations = iterations or DEFAULT_ITERATIONS + try: + salt = base64.b64decode(salt_b64, validate=True) + stored_hash = base64.b64decode(hash_b64, validate=True) + except (binascii.Error, ValueError): + return False + + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return hmac.compare_digest(derived, stored_hash) +``` + +**Step 4: Register PBKDF2 algorithm** + +Add to end of `salt/pbkdf2_algorithm.py`: + +```python +from salt.algorithms import register_algorithm + +# Auto-register when module is imported +register_algorithm(PBKDF2Algorithm()) +``` + +**Step 5: Import PBKDF2 in algorithms module** + +Add to `salt/algorithms.py` at the end: + +```python +# Import to auto-register algorithms +from salt import pbkdf2_algorithm # noqa: E402, F401 +``` + +**Step 6: Run tests to verify they pass** + +Run: `pytest tests/test_algorithms.py -v` +Expected: All tests PASS + +**Step 7: Commit PBKDF2 algorithm** + +```bash +git add salt/pbkdf2_algorithm.py tests/test_algorithms.py salt/algorithms.py +git commit -m "feat: implement PBKDF2 algorithm with registry" +``` + +--- + +## Task 4: Implement Argon2 Algorithm + +**Files:** +- Create: `salt/argon2_algorithm.py` +- Modify: `tests/test_algorithms.py` + +**Step 1: Write failing test for Argon2 algorithm** + +Add to `tests/test_algorithms.py`: + +```python +def test_argon2_algorithm_hash_round_trip(): + """Verify Argon2 algorithm can hash and verify passwords.""" + algo = get_algorithm("argon2") + salt, hashed = algo.hash("test password") + assert algo.verify("test password", salt, hashed) + assert not algo.verify("wrong password", salt, hashed) + + +def test_argon2_algorithm_identifier(): + """Verify Argon2 algorithm has correct identifier.""" + algo = get_algorithm("argon2") + assert algo.identifier == "argon2" +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_algorithms.py::test_argon2_algorithm_hash_round_trip -v` +Expected: FAIL with "ValueError: Unknown algorithm: argon2" + +**Step 3: Implement Argon2 algorithm** + +Create `salt/argon2_algorithm.py`: + +```python +"""Argon2 algorithm implementation.""" + +from __future__ import annotations + +import base64 +import binascii + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, InvalidHashError + + +class Argon2Algorithm: + """Argon2id password hashing algorithm.""" + + identifier = "argon2" + + def __init__(self): + """Initialize with default Argon2 parameters.""" + self._hasher = PasswordHasher() + + def hash(self, password: str, **kwargs) -> tuple[str, str]: + """Hash a password using Argon2id. + + Note: Argon2 generates its own salt internally and returns + a complete hash string that includes the salt and parameters. + We return empty string for salt_b64 and the full hash as hash_b64. + """ + hash_string = self._hasher.hash(password) + # Return empty salt since Argon2 embeds salt in hash + return ("", base64.b64encode(hash_string.encode("utf-8")).decode("utf-8")) + + def verify( + self, + password: str, + salt_b64: str, + hash_b64: str, + **kwargs, + ) -> bool: + """Verify a password against Argon2 hash. + + Note: salt_b64 is ignored since Argon2 embeds salt in the hash. + """ + try: + hash_string = base64.b64decode(hash_b64, validate=True).decode("utf-8") + self._hasher.verify(hash_string, password) + return True + except (VerifyMismatchError, InvalidHashError, binascii.Error, ValueError, UnicodeDecodeError): + return False +``` + +**Step 4: Register Argon2 algorithm** + +Add to end of `salt/argon2_algorithm.py`: + +```python +from salt.algorithms import register_algorithm + +# Auto-register when module is imported +register_algorithm(Argon2Algorithm()) +``` + +**Step 5: Import Argon2 in algorithms module** + +Modify `salt/algorithms.py` to import argon2: + +```python +# Import to auto-register algorithms +from salt import pbkdf2_algorithm # noqa: E402, F401 +from salt import argon2_algorithm # noqa: E402, F401 +``` + +**Step 6: Run tests to verify they pass** + +Run: `pytest tests/test_algorithms.py::test_argon2_algorithm_hash_round_trip -v` +Expected: PASS + +**Step 7: Commit Argon2 algorithm** + +```bash +git add salt/argon2_algorithm.py tests/test_algorithms.py salt/algorithms.py +git commit -m "feat: implement Argon2 algorithm" +``` + +--- + +## Task 5: Implement bcrypt Algorithm + +**Files:** +- Create: `salt/bcrypt_algorithm.py` +- Modify: `tests/test_algorithms.py` + +**Step 1: Write failing test for bcrypt algorithm** + +Add to `tests/test_algorithms.py`: + +```python +def test_bcrypt_algorithm_hash_round_trip(): + """Verify bcrypt algorithm can hash and verify passwords.""" + algo = get_algorithm("bcrypt") + salt, hashed = algo.hash("test password") + assert algo.verify("test password", salt, hashed) + assert not algo.verify("wrong password", salt, hashed) + + +def test_bcrypt_algorithm_identifier(): + """Verify bcrypt algorithm has correct identifier.""" + algo = get_algorithm("bcrypt") + assert algo.identifier == "bcrypt" +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_algorithms.py::test_bcrypt_algorithm_hash_round_trip -v` +Expected: FAIL with "ValueError: Unknown algorithm: bcrypt" + +**Step 3: Implement bcrypt algorithm** + +Create `salt/bcrypt_algorithm.py`: + +```python +"""bcrypt algorithm implementation.""" + +from __future__ import annotations + +import base64 +import binascii + +import bcrypt as bcrypt_lib + + +class BcryptAlgorithm: + """bcrypt password hashing algorithm.""" + + identifier = "bcrypt" + + def hash(self, password: str, **kwargs) -> tuple[str, str]: + """Hash a password using bcrypt. + + Note: bcrypt generates its own salt internally and returns + a complete hash string that includes the salt. + We return empty string for salt_b64 and the full hash as hash_b64. + """ + hashed = bcrypt_lib.hashpw(password.encode("utf-8"), bcrypt_lib.gensalt()) + # Return empty salt since bcrypt embeds salt in hash + return ("", base64.b64encode(hashed).decode("utf-8")) + + def verify( + self, + password: str, + salt_b64: str, + hash_b64: str, + **kwargs, + ) -> bool: + """Verify a password against bcrypt hash. + + Note: salt_b64 is ignored since bcrypt embeds salt in the hash. + """ + try: + hashed = base64.b64decode(hash_b64, validate=True) + return bcrypt_lib.checkpw(password.encode("utf-8"), hashed) + except (binascii.Error, ValueError): + return False +``` + +**Step 4: Register bcrypt algorithm** + +Add to end of `salt/bcrypt_algorithm.py`: + +```python +from salt.algorithms import register_algorithm + +# Auto-register when module is imported +register_algorithm(BcryptAlgorithm()) +``` + +**Step 5: Import bcrypt in algorithms module** + +Modify `salt/algorithms.py` to import bcrypt: + +```python +# Import to auto-register algorithms +from salt import pbkdf2_algorithm # noqa: E402, F401 +from salt import argon2_algorithm # noqa: E402, F401 +from salt import bcrypt_algorithm # noqa: E402, F401 +``` + +**Step 6: Run tests to verify they pass** + +Run: `pytest tests/test_algorithms.py::test_bcrypt_algorithm_hash_round_trip -v` +Expected: PASS + +**Step 7: Commit bcrypt algorithm** + +```bash +git add salt/bcrypt_algorithm.py tests/test_algorithms.py salt/algorithms.py +git commit -m "feat: implement bcrypt algorithm" +``` + +--- + +## Task 6: Refactor salt.py to Use Algorithm Interface + +**Files:** +- Modify: `salt.py` +- Modify: `tests/test_hashing.py` + +**Step 1: Write test for algorithm parameter in hash_password** + +Add to `tests/test_hashing.py`: + +```python +def test_hash_password_with_algorithm_parameter(): + """Verify hash_password accepts algorithm parameter.""" + salt, hashed = hash_password("test", algorithm="pbkdf2") + assert verify_password("test", salt, hashed, algorithm="pbkdf2") +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_hashing.py::test_hash_password_with_algorithm_parameter -v` +Expected: FAIL with "TypeError: hash_password() got an unexpected keyword argument 'algorithm'" + +**Step 3: Update hash_password to accept algorithm parameter** + +Modify `salt.py`: + +```python +def hash_password( + password: str, + *, + algorithm: str = "pbkdf2", + iterations: int | None = None, + salt_bytes: int = DEFAULT_SALT_BYTES, +) -> tuple[str, str]: + """Return a base64 encoded salt and hash for ``password``.""" + from salt.algorithms import get_algorithm + + algo = get_algorithm(algorithm) + return algo.hash(password, iterations=iterations, salt_bytes=salt_bytes) +``` + +**Step 4: Update verify_password to accept algorithm parameter** + +Modify `salt.py`: + +```python +def verify_password( + password: str, + salt_b64: str, + hash_b64: str, + *, + algorithm: str = "pbkdf2", + iterations: int | None = None, +) -> bool: + """Validate ``password`` against the provided base64 salt + hash pair.""" + from salt.algorithms import get_algorithm + + algo = get_algorithm(algorithm) + return algo.verify(password, salt_b64, hash_b64, iterations=iterations) +``` + +**Step 5: Run all tests to verify backward compatibility** + +Run: `pytest tests/test_hashing.py -v` +Expected: All tests PASS (existing tests still work) + +**Step 6: Commit refactored core functions** + +```bash +git add salt.py tests/test_hashing.py +git commit -m "refactor: update hash_password and verify_password to use algorithm interface" +``` + +--- + +## Task 7: Add CLI Algorithm Support to salt.py + +**Files:** +- Modify: `salt.py` +- Modify: `tests/test_cli.py` + +**Step 1: Write test for CLI algorithm parameter** + +Add to `tests/test_cli.py`: + +```python +def test_main_hash_with_algorithm_flag(): + """Verify CLI accepts --algorithm flag.""" + assert main(["hash", "--algorithm", "pbkdf2", "secret"]) == 0 + + +def test_main_verify_with_algorithm_flag(): + """Verify CLI verify accepts --algorithm flag.""" + from salt import hash_password + salt, hashed = hash_password("secret", algorithm="pbkdf2") + assert main(["verify", "--algorithm", "pbkdf2", "secret", salt, hashed]) == 0 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_cli.py::test_main_hash_with_algorithm_flag -v` +Expected: FAIL with "unrecognized arguments: --algorithm" + +**Step 3: Add --algorithm flag to CLI parser** + +Modify `_build_parser()` in `salt.py`: + +```python +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Erstellt sichere PBKDF2-Hashes oder verifiziert bestehende Werte." + ) + subparsers = parser.add_subparsers(dest="command") + + hash_parser = subparsers.add_parser( + "hash", help="Erstellt ein Salt und einen Hash für ein Passwort." + ) + hash_parser.add_argument("password", help="Klartext-Passwort zum Hashen.") + hash_parser.add_argument( + "--algorithm", + "-a", + default="pbkdf2", + help="Hash-Algorithmus (pbkdf2, argon2, bcrypt). Standard: pbkdf2", + ) + + verify_parser = subparsers.add_parser("verify", help="Überprüft ein Passwort.") + verify_parser.add_argument("password", help="Klartext-Passwort zur Überprüfung.") + verify_parser.add_argument("salt", help="Base64-kodiertes Salt.") + verify_parser.add_argument("hash", help="Base64-kodierter Hash.") + verify_parser.add_argument( + "--algorithm", + "-a", + default="pbkdf2", + help="Hash-Algorithmus (pbkdf2, argon2, bcrypt). Standard: pbkdf2", + ) + return parser +``` + +**Step 4: Update main() to use algorithm parameter** + +Modify `main()` in `salt.py`: + +```python +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + arg_list = list(argv) if argv is not None else sys.argv[1:] + args = parser.parse_args(_normalize_args(arg_list)) + + if args.command == "verify": + if verify_password(args.password, args.salt, args.hash, algorithm=args.algorithm): + print("✓ Passwort korrekt") + return 0 + print("✗ Passwort falsch") + return 1 + + if args.command != "hash": + parser.error('Bitte einen Hash generieren oder "verify" verwenden.') + + salt, hash_value = hash_password(args.password, algorithm=args.algorithm) + print(f"Salt: {salt}") + print(f"Hash: {hash_value}") + return 0 +``` + +**Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_cli.py -v` +Expected: All tests PASS + +**Step 6: Commit CLI algorithm support** + +```bash +git add salt.py tests/test_cli.py +git commit -m "feat: add --algorithm flag to CLI" +``` + +--- + +## Task 8: Add CLI Algorithm Support to salt2.py + +**Files:** +- Modify: `salt2.py` +- Create: `tests/test_cli2.py` + +**Step 1: Write test for salt2.py CLI algorithm parameter** + +Create `tests/test_cli2.py`: + +```python +from salt import hash_password +from salt2 import main + + +def test_main_generate_with_algorithm_flag(): + """Verify salt2 CLI accepts --algorithm flag.""" + assert main(["generate", "--algorithm", "pbkdf2", "secret"]) == 0 + + +def test_main_verify_with_algorithm_flag(): + """Verify salt2 CLI verify accepts --algorithm flag.""" + salt, hashed = hash_password("secret", algorithm="pbkdf2") + assert main(["verify", "--algorithm", "pbkdf2", "secret", salt, hashed]) == 0 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_cli2.py::test_main_generate_with_algorithm_flag -v` +Expected: FAIL with "unrecognized arguments: --algorithm" + +**Step 3: Add --algorithm flag to salt2.py parser** + +Modify `_build_parser()` in `salt2.py`: + +```python +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="CLI für PBKDF2-Hashing inklusive Verify-Modus." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + generate_parser = subparsers.add_parser( + "generate", help="Erzeugt ein neues Salt+Hash Paar." + ) + generate_parser.add_argument("password", help="Klartext-Passwort zum Hashen.") + generate_parser.add_argument( + "--algorithm", + "-a", + default="pbkdf2", + help="Hash-Algorithmus (pbkdf2, argon2, bcrypt). Standard: pbkdf2", + ) + + verify_parser = subparsers.add_parser( + "verify", + help="Validiert ein Passwort anhand von Salt und Hash.", + ) + verify_parser.add_argument("password", help="Passwort zum Prüfen.") + verify_parser.add_argument("salt", help="Base64-kodiertes Salt.") + verify_parser.add_argument("hash", help="Base64-kodierter Hash.") + verify_parser.add_argument( + "--algorithm", + "-a", + default="pbkdf2", + help="Hash-Algorithmus (pbkdf2, argon2, bcrypt). Standard: pbkdf2", + ) + return parser +``` + +**Step 4: Update command handlers to use algorithm parameter** + +Modify `_command_generate()` and `_command_verify()` in `salt2.py`: + +```python +def _command_generate(args: argparse.Namespace) -> int: + salt, hash_value = hash_password(args.password, algorithm=args.algorithm) + print(f"Salt: {salt}") + print(f"Hash: {hash_value}") + return 0 + + +def _command_verify(args: argparse.Namespace) -> int: + if verify_password(args.password, args.salt, args.hash, algorithm=args.algorithm): + print("✓ Passwort korrekt") + return 0 + print("✗ Passwort falsch") + return 1 +``` + +**Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_cli2.py -v` +Expected: All tests PASS + +**Step 6: Commit salt2.py algorithm support** + +```bash +git add salt2.py tests/test_cli2.py +git commit -m "feat: add --algorithm flag to salt2 CLI" +``` + +--- + +## Task 9: Add Algorithm Selection to CLI Help + +**Files:** +- Modify: `salt.py` +- Modify: `salt2.py` + +**Step 1: Add algorithm list command to salt.py** + +Modify `_build_parser()` in `salt.py` to add a list subcommand: + +```python +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Erstellt sichere PBKDF2-Hashes oder verifiziert bestehende Werte." + ) + subparsers = parser.add_subparsers(dest="command") + + # ... existing hash and verify parsers ... + + subparsers.add_parser( + "list-algorithms", + help="Zeigt alle verfügbaren Hash-Algorithmen an.", + ) + + return parser +``` + +**Step 2: Add list-algorithms handler to main()** + +Modify `main()` in `salt.py`: + +```python +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + arg_list = list(argv) if argv is not None else sys.argv[1:] + args = parser.parse_args(_normalize_args(arg_list)) + + if args.command == "list-algorithms": + from salt.algorithms import list_algorithms + print("Verfügbare Algorithmen:") + for algo in list_algorithms(): + print(f" - {algo}") + return 0 + + if args.command == "verify": + # ... existing verify code ... + + if args.command != "hash": + parser.error('Bitte einen Hash generieren oder "verify" verwenden.') + + # ... existing hash code ... +``` + +**Step 3: Test list-algorithms command** + +Run: `python3 salt.py list-algorithms` +Expected: Output showing "pbkdf2", "argon2", "bcrypt" + +**Step 4: Add same functionality to salt2.py** + +Apply similar changes to `salt2.py` `_build_parser()` and add a `_command_list_algorithms()` function. + +**Step 5: Commit algorithm listing** + +```bash +git add salt.py salt2.py +git commit -m "feat: add list-algorithms command to CLI" +``` + +--- + +## Task 10: Update Documentation + +**Files:** +- Modify: `CLAUDE.md` + +**Step 1: Update CLAUDE.md with algorithm documentation** + +Add section to `CLAUDE.md` after "## Common Commands": + +```markdown +### Algorithm Support + +The utility now supports multiple hashing algorithms: +- **pbkdf2** (default): PBKDF2-HMAC-SHA256 with 200,000 iterations +- **argon2**: Argon2id with default parameters from argon2-cffi +- **bcrypt**: bcrypt with default cost factor + +```bash +# List available algorithms +python3 salt.py list-algorithms + +# Hash with specific algorithm +python3 salt.py hash --algorithm argon2 "MyPassword" +python3 salt2.py generate --algorithm bcrypt "MyPassword" + +# Verify with specific algorithm +python3 salt.py verify --algorithm pbkdf2 +``` + +**Note:** Argon2 and bcrypt embed the salt within their hash output, so the salt field may be empty for these algorithms. +``` + +**Step 2: Update Key Functions section** + +Update the function signatures in CLAUDE.md: + +```markdown +**`hash_password(password, *, algorithm="pbkdf2", iterations=None, salt_bytes=16) -> tuple[str, str]`** +- Supports multiple algorithms: pbkdf2, argon2, bcrypt +- Generates a cryptographically secure random salt (or uses algorithm's embedded salt) +- Returns base64-encoded (salt, hash) tuple + +**`verify_password(password, salt_b64, hash_b64, *, algorithm="pbkdf2", iterations=None) -> bool`** +- Validates a password against stored salt/hash using specified algorithm +- Uses timing-safe comparison when applicable +- Returns `False` for invalid base64 without raising exceptions +``` + +**Step 3: Commit documentation updates** + +```bash +git add CLAUDE.md +git commit -m "docs: update documentation for multi-algorithm support" +``` + +--- + +## Task 11: Integration Testing + +**Files:** +- Create: `tests/test_integration.py` + +**Step 1: Write integration tests** + +Create `tests/test_integration.py`: + +```python +"""Integration tests for multi-algorithm password hashing.""" + +import subprocess +import sys + + +def test_pbkdf2_cli_integration(): + """Test PBKDF2 end-to-end via CLI.""" + result = subprocess.run( + [sys.executable, "salt.py", "hash", "--algorithm", "pbkdf2", "test123"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Salt:" in result.stdout + assert "Hash:" in result.stdout + + +def test_argon2_cli_integration(): + """Test Argon2 end-to-end via CLI.""" + result = subprocess.run( + [sys.executable, "salt.py", "hash", "--algorithm", "argon2", "test123"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Hash:" in result.stdout + + +def test_bcrypt_cli_integration(): + """Test bcrypt end-to-end via CLI.""" + result = subprocess.run( + [sys.executable, "salt.py", "hash", "--algorithm", "bcrypt", "test123"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Hash:" in result.stdout + + +def test_list_algorithms_integration(): + """Test list-algorithms command.""" + result = subprocess.run( + [sys.executable, "salt.py", "list-algorithms"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "argon2" in result.stdout + assert "bcrypt" in result.stdout + assert "pbkdf2" in result.stdout +``` + +**Step 2: Run integration tests** + +Run: `pytest tests/test_integration.py -v` +Expected: All tests PASS + +**Step 3: Run full test suite** + +Run: `pytest -v` +Expected: All tests PASS, coverage >90% + +**Step 4: Commit integration tests** + +```bash +git add tests/test_integration.py +git commit -m "test: add integration tests for multi-algorithm support" +``` + +--- + +## Task 12: Backward Compatibility Verification + +**Files:** +- Modify: `tests/test_hashing.py` + +**Step 1: Write backward compatibility test** + +Add to `tests/test_hashing.py`: + +```python +def test_backward_compatibility_with_old_pbkdf2_hashes(): + """Verify existing PBKDF2 hashes still work without algorithm parameter.""" + # Simulate old hash created before algorithm parameter existed + salt, hashed = hash_password("legacy-password") + + # Verify using old API (no algorithm parameter) + assert verify_password("legacy-password", salt, hashed) + assert not verify_password("wrong", salt, hashed) + + # Verify using new API with explicit pbkdf2 + assert verify_password("legacy-password", salt, hashed, algorithm="pbkdf2") +``` + +**Step 2: Run backward compatibility test** + +Run: `pytest tests/test_hashing.py::test_backward_compatibility_with_old_pbkdf2_hashes -v` +Expected: PASS + +**Step 3: Verify all existing tests still pass** + +Run: `pytest tests/test_hashing.py tests/test_cli.py -v` +Expected: All existing tests PASS (no regressions) + +**Step 4: Commit backward compatibility test** + +```bash +git add tests/test_hashing.py +git commit -m "test: add backward compatibility verification" +``` + +--- + +## Verification Checklist + +Run these commands to verify the implementation: + +- [ ] `pytest -v` - All tests pass +- [ ] `pytest --cov=salt --cov-report=term-missing` - Coverage >90% +- [ ] `python3 salt.py list-algorithms` - Shows all 3 algorithms +- [ ] `python3 salt.py hash --algorithm pbkdf2 "test"` - Works +- [ ] `python3 salt.py hash --algorithm argon2 "test"` - Works +- [ ] `python3 salt.py hash --algorithm bcrypt "test"` - Works +- [ ] `python3 salt.py "test"` - Shortcut still works (uses pbkdf2) +- [ ] `python3 salt2.py generate --algorithm argon2 "test"` - Works +- [ ] Verify no regressions in existing functionality + +--- + +## Architecture Notes for Engineer + +**Algorithm Registration Pattern:** +- Each algorithm implements the `Algorithm` protocol (hash, verify, identifier) +- Algorithms auto-register themselves on import via `register_algorithm()` +- Registry is in `salt/algorithms.py` with `get_algorithm()` and `list_algorithms()` +- Import side-effects ensure all algorithms are registered at module load time + +**Backward Compatibility:** +- Default algorithm is "pbkdf2" +- All existing code works without modification +- `algorithm` parameter is optional with default value +- Existing PBKDF2 hashes verify correctly + +**Salt Handling:** +- PBKDF2: Generates separate salt, returns as first tuple element +- Argon2: Embeds salt in hash string, returns empty string for salt +- bcrypt: Embeds salt in hash string, returns empty string for salt + +**Testing Strategy:** +- Unit tests per algorithm in `tests/test_algorithms.py` +- Integration tests in `tests/test_integration.py` +- CLI tests in `tests/test_cli.py` and `tests/test_cli2.py` +- Backward compatibility test in `tests/test_hashing.py` + +**Design Principles:** +- DRY: Common interface, no duplication +- YAGNI: Only implement requested algorithms +- TDD: Write failing test first, minimal implementation, then pass +- Frequent commits: One logical change per commit diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f391b38 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pytest>=7.4 +argon2-cffi>=23.1.0 +bcrypt>=4.1.0 diff --git a/salt.py b/salt.py new file mode 100644 index 0000000..b009f13 --- /dev/null +++ b/salt.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""PBKDF2 helper utilities for hashing and verifying passwords.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import hmac +import os +import sys +from typing import Sequence + +DEFAULT_SALT_BYTES = 16 +DEFAULT_ITERATIONS = int(os.environ.get("PBKDF2_ITERATIONS", "200000")) + + +def hash_password( + password: str, + *, + iterations: int | None = None, + salt_bytes: int = DEFAULT_SALT_BYTES, +) -> tuple[str, str]: + """Return a base64 encoded salt and hash for ``password``.""" + iterations = iterations or DEFAULT_ITERATIONS + salt = os.urandom(salt_bytes) + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return ( + base64.b64encode(salt).decode("utf-8"), + base64.b64encode(derived).decode("utf-8"), + ) + + +def verify_password( + password: str, + salt_b64: str, + hash_b64: str, + *, + iterations: int | None = None, +) -> bool: + """Validate ``password`` against the provided base64 salt + hash pair.""" + iterations = iterations or DEFAULT_ITERATIONS + try: + salt = base64.b64decode(salt_b64, validate=True) + stored_hash = base64.b64decode(hash_b64, validate=True) + except (binascii.Error, ValueError): + return False + + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + ) + return hmac.compare_digest(derived, stored_hash) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Erstellt sichere PBKDF2-Hashes oder verifiziert bestehende Werte." + ) + subparsers = parser.add_subparsers(dest="command") + hash_parser = subparsers.add_parser( + "hash", help="Erstellt ein Salt und einen Hash für ein Passwort." + ) + hash_parser.add_argument("password", help="Klartext-Passwort zum Hashen.") + verify_parser = subparsers.add_parser("verify", help="Überprüft ein Passwort.") + verify_parser.add_argument("password", help="Klartext-Passwort zur Überprüfung.") + verify_parser.add_argument("salt", help="Base64-kodiertes Salt.") + verify_parser.add_argument("hash", help="Base64-kodierter Hash.") + return parser + + +def _normalize_args(argv: Sequence[str] | None) -> list[str]: + """Erlaube ``python3 salt.py `` als Abkürzung für ``hash``.""" + if not argv: + return [] + if argv[0] in {"hash", "verify"} or argv[0].startswith("-"): + return list(argv) + return ["hash", *argv] + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + arg_list = list(argv) if argv is not None else sys.argv[1:] + args = parser.parse_args(_normalize_args(arg_list)) + + if args.command == "verify": + if verify_password(args.password, args.salt, args.hash): + print("✓ Passwort korrekt") + return 0 + print("✗ Passwort falsch") + return 1 + + if args.command != "hash": + parser.error('Bitte einen Hash generieren oder "verify" verwenden.') + + salt, hash_value = hash_password(args.password) + print(f"Salt: {salt}") + print(f"Hash: {hash_value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/salt2.py b/salt2.py new file mode 100644 index 0000000..a3fb092 --- /dev/null +++ b/salt2.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Command line interface around the salt hashing helpers.""" + +from __future__ import annotations + +import argparse +from typing import Sequence + +from salt import hash_password, verify_password + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="CLI für PBKDF2-Hashing inklusive Verify-Modus." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + generate_parser = subparsers.add_parser( + "generate", help="Erzeugt ein neues Salt+Hash Paar." + ) + generate_parser.add_argument("password", help="Klartext-Passwort zum Hashen.") + + verify_parser = subparsers.add_parser( + "verify", + help="Validiert ein Passwort anhand von Salt und Hash.", + ) + verify_parser.add_argument("password", help="Passwort zum Prüfen.") + verify_parser.add_argument("salt", help="Base64-kodiertes Salt.") + verify_parser.add_argument("hash", help="Base64-kodierter Hash.") + return parser + + +def _command_generate(args: argparse.Namespace) -> int: + salt, hash_value = hash_password(args.password) + print(f"Salt: {salt}") + print(f"Hash: {hash_value}") + return 0 + + +def _command_verify(args: argparse.Namespace) -> int: + if verify_password(args.password, args.salt, args.hash): + print("✓ Passwort korrekt") + return 0 + print("✗ Passwort falsch") + return 1 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if args.command == "generate": + return _command_generate(args) + return _command_verify(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__pycache__/test_cli.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_cli.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35c11bc89738e6842975a00e2cbe545e91e979b0 GIT binary patch literal 2904 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 n*UFHYep6&w{!x-{NT0X;EDda^iafT_LFA) None: + assert main(["secret"]) == 0 + + +def test_main_verify_round_trip() -> None: + salt, hashed = hash_password("secret-value") + assert main(["verify", "secret-value", salt, hashed]) == 0 + assert main(["verify", "wrong", salt, hashed]) == 1 diff --git a/tests/test_hashing.py b/tests/test_hashing.py new file mode 100644 index 0000000..5498e4e --- /dev/null +++ b/tests/test_hashing.py @@ -0,0 +1,20 @@ +import re + +from salt import hash_password, verify_password + + +def test_hash_password_round_trip() -> None: + salt, hashed = hash_password("correct horse battery staple") + assert verify_password("correct horse battery staple", salt, hashed) + assert not verify_password("wrong", salt, hashed) + + +def test_hash_password_returns_base64() -> None: + salt, hashed = hash_password("secret") + base64_pattern = re.compile(r"^[A-Za-z0-9+/]+={0,2}$") + assert base64_pattern.fullmatch(salt) + assert base64_pattern.fullmatch(hashed) + + +def test_verify_password_handles_invalid_base64() -> None: + assert verify_password("secret", "**invalid**", "???") is False