chore: initial commit with existing codebase
This commit is contained in:
25
AGENTS.md
Normal file
25
AGENTS.md
Normal file
@@ -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 <pw> <salt> <hash> # 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_<feature>.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.
|
||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
@@ -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 <password>` 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 <password> <salt_b64> <hash_b64>
|
||||
|
||||
# Alternative CLI (requires explicit subcommands)
|
||||
python3 salt2.py generate "MyPassword"
|
||||
python3 salt2.py verify <password> <salt_b64> <hash_b64>
|
||||
```
|
||||
|
||||
### 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_<module>.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.
|
||||
BIN
__pycache__/salt.cpython-312.pyc
Normal file
BIN
__pycache__/salt.cpython-312.pyc
Normal file
Binary file not shown.
1093
docs/plans/2025-11-13-multi-algorithm-support.md
Normal file
1093
docs/plans/2025-11-13-multi-algorithm-support.md
Normal file
File diff suppressed because it is too large
Load Diff
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pytest>=7.4
|
||||
argon2-cffi>=23.1.0
|
||||
bcrypt>=4.1.0
|
||||
111
salt.py
Normal file
111
salt.py
Normal file
@@ -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 <passwort>`` 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())
|
||||
57
salt2.py
Normal file
57
salt2.py
Normal file
@@ -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())
|
||||
BIN
tests/__pycache__/test_cli.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_cli.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_hashing.cpython-312-pytest-9.0.1.pyc
Normal file
BIN
tests/__pycache__/test_hashing.cpython-312-pytest-9.0.1.pyc
Normal file
Binary file not shown.
11
tests/test_cli.py
Normal file
11
tests/test_cli.py
Normal file
@@ -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
|
||||
20
tests/test_hashing.py
Normal file
20
tests/test_hashing.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user