fix: use hmac.compare_digest for constant-time credential comparison in auth_svc#3297
fix: use hmac.compare_digest for constant-time credential comparison in auth_svc#3297deacon-mp wants to merge 3 commits into
Conversation
Replace == operator with hmac.compare_digest() in get_permissions() to prevent timing-based side-channel attacks on API key comparison.
There was a problem hiding this comment.
Pull request overview
This PR hardens auth_svc against timing attacks by switching credential/API-key equality checks to constant-time comparisons.
Changes:
- Replace
==-based API key comparisons withhmac.compare_digest()inAuthService.get_permissions() - Add unit tests validating API key-based permission outcomes (red/blue/wrong/missing)
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| app/service/auth_svc.py | Uses constant-time comparison for API key checks and avoids matching on empty configured keys |
| tests/test_compare_digest_auth.py | Adds tests around API key permission behavior (red/blue/wrong/none) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| request_key = str(request.headers.get(HEADER_API_KEY) or '') | ||
| red_key = str(self.get_config(CONFIG_API_KEY_RED) or '') | ||
| blue_key = str(self.get_config(CONFIG_API_KEY_BLUE) or '') | ||
| if red_key and compare_digest(request_key, red_key): | ||
| return self.Access.RED, self.Access.APP | ||
| elif request.headers.get(HEADER_API_KEY) == self.get_config(CONFIG_API_KEY_BLUE): | ||
| elif blue_key and compare_digest(request_key, blue_key): |
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('RED_KEY_123') | ||
| result = asyncio.run(svc.get_permissions(request)) |
| def test_red_key_returns_red_access(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('RED_KEY_123') | ||
| result = asyncio.run(svc.get_permissions(request)) | ||
| assert BaseWorld.Access.RED in result | ||
| assert BaseWorld.Access.APP in result | ||
|
|
||
| def test_blue_key_returns_blue_access(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('BLUE_KEY_456') | ||
| result = asyncio.run(svc.get_permissions(request)) | ||
| assert BaseWorld.Access.BLUE in result | ||
| assert BaseWorld.Access.APP in result | ||
|
|
||
| def test_wrong_key_returns_empty(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('WRONG_KEY') | ||
| result = asyncio.run(svc.get_permissions(request)) | ||
| assert result == () | ||
|
|
||
| def test_no_key_returns_empty(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request(None) | ||
| result = asyncio.run(svc.get_permissions(request)) |
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('WRONG_KEY') | ||
| result = asyncio.run(svc.get_permissions(request)) |
| def test_red_key_returns_red_access(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('RED_KEY_123') | ||
| result = asyncio.run(svc.get_permissions(request)) | ||
| assert BaseWorld.Access.RED in result | ||
| assert BaseWorld.Access.APP in result | ||
|
|
||
| def test_blue_key_returns_blue_access(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('BLUE_KEY_456') | ||
| result = asyncio.run(svc.get_permissions(request)) | ||
| assert BaseWorld.Access.BLUE in result | ||
| assert BaseWorld.Access.APP in result | ||
|
|
||
| def test_wrong_key_returns_empty(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request('WRONG_KEY') | ||
| result = asyncio.run(svc.get_permissions(request)) | ||
| assert result == () | ||
|
|
||
| def test_no_key_returns_empty(self): | ||
| svc = AuthService.__new__(AuthService) | ||
| svc.user_map = {} | ||
| request = self._make_request(None) | ||
| result = asyncio.run(svc.get_permissions(request)) |
- Remove asyncio import (no longer needed) - Convert all test methods to async def with @pytest.mark.asyncio (avoids asyncio.run() which breaks when an event loop is already running, as is common in pytest-asyncio environments)
There was a problem hiding this comment.
Pull request overview
This PR hardens AuthService.get_permissions against timing attacks by replacing direct string equality checks for API keys with constant-time comparisons, and adds tests around API key authentication behavior.
Changes:
- Replace
==API key comparisons withcompare_digestinapp/service/auth_svc.py - Add pytest coverage for red/blue/wrong/missing API key flows
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| app/service/auth_svc.py | Switch API key comparisons to constant-time digest comparison and avoid matching on falsy/missing config values |
| tests/test_compare_digest_auth.py | Add async tests validating expected permission outcomes for red/blue/wrong/missing API keys |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import pytest | ||
| from unittest.mock import MagicMock, AsyncMock | ||
| from app.service.auth_svc import AuthService, HEADER_API_KEY | ||
| from app.utility.base_world import BaseWorld |
| request_key = str(request.headers.get(HEADER_API_KEY) or '') | ||
| red_key = str(self.get_config(CONFIG_API_KEY_RED) or '') | ||
| blue_key = str(self.get_config(CONFIG_API_KEY_BLUE) or '') | ||
| if red_key and compare_digest(request_key, red_key): | ||
| return self.Access.RED, self.Access.APP | ||
| elif request.headers.get(HEADER_API_KEY) == self.get_config(CONFIG_API_KEY_BLUE): | ||
| elif blue_key and compare_digest(request_key, blue_key): | ||
| return self.Access.BLUE, self.Access.APP |
| request_key = str(request.headers.get(HEADER_API_KEY) or '') | ||
| red_key = str(self.get_config(CONFIG_API_KEY_RED) or '') | ||
| blue_key = str(self.get_config(CONFIG_API_KEY_BLUE) or '') |
…fore compare_digest, add bytes config test
There was a problem hiding this comment.
Pull request overview
This PR hardens authentication credential/API key comparisons against timing attacks by switching to constant-time comparison and adding coverage for API key auth flows.
Changes:
- Added
_ensure_str()helper to normalize config/header values prior to constant-time comparison. - Replaced API key equality checks with
compare_digest()inAuthService.get_permissions(). - Added new pytest coverage for API key permission resolution and byte/str normalization.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| app/service/auth_svc.py | Uses constant-time comparison for API key auth and normalizes values via _ensure_str(). |
| tests/test_compare_digest_auth.py | Adds tests for API key permission behavior and _ensure_str() handling of bytes config values. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _ensure_str(value) -> str: | ||
| """Convert a value to str safely, decoding bytes instead of using str() repr.""" | ||
| if isinstance(value, bytes): | ||
| return value.decode('utf-8', errors='replace') |
| assert result == () | ||
|
|
||
| def test_ensure_str_decodes_bytes(self): | ||
| """bytes values should be decoded, not repr'd as 'b\"...\"'.""" |
|
obsolete: see #3257 |
Summary
The auth service was using
==for string comparison of API keys and credentials, making it vulnerable to timing attacks. An attacker can exploit timing differences to enumerate valid credentials byte-by-byte. Changed tohmac.compare_digest()for constant-time comparison.Changes
app/service/auth_svc.py: replaced==comparisons for credentials/API keys withhmac.compare_digest()Security Impact
Timing attacks against string equality can allow credential enumeration.
hmac.compare_digest()runs in constant time regardless of where strings diverge, eliminating this side-channel.Test plan