TASK-031: Enforce bcrypt 72-byte password limit

Bcrypt silently truncates passwords at 72 bytes, so passwords longer than 72
characters provide no additional security. This commit enforces the 72-byte
maximum across the authentication and setup flows.

Changes:
- Add max_length=72 to LoginRequest.password and SetupRequest.master_password
- Update field validator in SetupRequest to explicitly check max_length
- Add comprehensive tests for password length validation (6 new test cases)
- Document the 72-byte limitation in Features.md (master password options)
- Add new section 12 'Password Hashing' in Backend-Development.md explaining:
  - The bcrypt truncation behavior
  - Why the limit is enforced
  - The validation flow from frontend to backend
  - What happens when passwords exceed the limit

All existing tests pass, no regressions introduced.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 15:38:20 +02:00
parent 1d91e24a88
commit 32aad186c3
5 changed files with 121 additions and 7 deletions

View File

@@ -289,3 +289,78 @@ def test_global_config_response_log_target_invalid_path(_mock_allowed_dirs: None
)
error_msg = str(exc_info.value)
assert "outside allowed directories" in error_msg
# ---------------------------------------------------------------------------
# LoginRequest and SetupRequest password validation
# ---------------------------------------------------------------------------
def test_login_request_password_accepted_at_72_bytes() -> None:
"""LoginRequest accepts passwords exactly 72 bytes long."""
from app.models.auth import LoginRequest
password_72 = "a" * 72
req = LoginRequest(password=password_72)
assert req.password == password_72
def test_login_request_password_rejected_over_72_bytes() -> None:
"""LoginRequest rejects passwords exceeding 72 bytes."""
from app.models.auth import LoginRequest
password_73 = "a" * 73
with pytest.raises(ValidationError) as exc_info:
LoginRequest(password=password_73)
error_msg = str(exc_info.value)
assert "at most 72 characters" in error_msg or "max_length" in error_msg
def test_setup_request_master_password_accepted_at_72_bytes() -> None:
"""SetupRequest accepts master_password exactly 72 bytes long."""
from app.models.setup import SetupRequest
password_72 = "Password1!" + "a" * 61 # 72 chars total with required complexity
req = SetupRequest(master_password=password_72)
assert req.master_password == password_72
def test_setup_request_master_password_rejected_over_72_bytes() -> None:
"""SetupRequest rejects master_password exceeding 72 bytes."""
from app.models.setup import SetupRequest
password_73 = "Password1!" + "a" * 63 # 73 chars total
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password=password_73)
error_msg = str(exc_info.value)
assert "at most 72 characters" in error_msg or "string_too_long" in error_msg
def test_setup_request_master_password_min_length_still_enforced() -> None:
"""SetupRequest still enforces minimum password length (8 characters)."""
from app.models.setup import SetupRequest
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="Short1!")
error_msg = str(exc_info.value)
assert "8 characters" in error_msg
def test_setup_request_master_password_complexity_still_enforced() -> None:
"""SetupRequest still enforces password complexity requirements."""
from app.models.setup import SetupRequest
# Missing uppercase
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="password123!")
assert "uppercase" in str(exc_info.value)
# Missing number
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="Password!")
assert "number" in str(exc_info.value)
# Missing special character
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="Password1")
assert "special character" in str(exc_info.value)