commit 18c991181a81e2ea50b0d6175afef6e68c1b180e Author: Joachim Hummel Date: Thu Nov 13 13:08:49 2025 +0000 chore: initial commit with existing codebase 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 0000000..bde6280 Binary files /dev/null and b/__pycache__/salt.cpython-312.pyc differ diff --git a/docs/plans/2025-11-13-multi-algorithm-support.md b/docs/plans/2025-11-13-multi-algorithm-support.md new file mode 100644 index 0000000..5924951 --- /dev/null +++ b/docs/plans/2025-11-13-multi-algorithm-support.md @@ -0,0 +1,1093 @@ +# Multi-Algorithm Password Hashing Implementation Plan + +> **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 0000000..35c11bc Binary files /dev/null and b/tests/__pycache__/test_cli.cpython-312-pytest-9.0.1.pyc differ diff --git a/tests/__pycache__/test_hashing.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_hashing.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000..0b00562 Binary files /dev/null and b/tests/__pycache__/test_hashing.cpython-312-pytest-9.0.1.pyc differ diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f5808b6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,11 @@ +from salt import hash_password, main + + +def test_main_supports_hash_shortcut() -> 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