diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8160a5c..91e1288 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -36,8 +36,8 @@ ___ Environment =========== -- FastAPI Guard Agent version: [e.g. 1.0.2] -- FastAPI Guard version: [e.g. 4.0.2] +- FastAPI Guard Agent version: [e.g. 1.1.0] +- FastAPI Guard version: [e.g. 4.1.2] - Python version: [e.g. 3.11.10] - FastAPI version: [e.g. 0.115.0] - Redis version: [e.g. 7.0.12] diff --git a/.mike.yml b/.mike.yml index 460f7a9..6169e2f 100644 --- a/.mike.yml +++ b/.mike.yml @@ -2,6 +2,7 @@ version_selector: true title_switch: true versions_file: docs/versions/versions.json versions: + - 1.1.0 - 1.0.2 - 1.0.1 - 1.0.0 @@ -10,4 +11,4 @@ versions: - 0.0.1 - latest aliases: - latest: 1.0.2 \ No newline at end of file + latest: 1.1.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0059164..197d894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,30 @@ Release Notes ___ +v1.1.0 (2025-10-14) +------------------- + +New Features (v1.1.0) +--------------------- +- Added end-to-end payload encryption for secure telemetry transmission using AES-256-GCM. +- Implemented `PayloadEncryptor` class with project-specific encryption keys. +- Added encrypted endpoint support for events and metrics (`/api/v1/events/encrypted`). +- Integrated automatic datetime serialization in encrypted payloads via custom JSON handler. +- Added encryption key verification during transport initialization. + +Technical Details (v1.1.0) +-------------------------- +- Encryption uses AES-256-GCM with 96-bit nonces and 128-bit authentication tags. +- Pydantic models are serialized using `.model_dump(mode="json")` before encryption. +- Custom `_default_json_handler` ensures datetime objects are properly ISO-formatted. + +___ + v1.0.2 (2025-09-12) ------------------- Enhancements (v1.0.2) ------------- +--------------------- - Added dynamic rule updated event type. ___ diff --git a/docs/release-notes.md b/docs/release-notes.md index 6304b8b..e2c85b7 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -3,11 +3,30 @@ Release Notes ___ +v1.1.0 (2025-10-14) +------------------- + +New Features (v1.1.0) +--------------------- +- Added end-to-end payload encryption for secure telemetry transmission using AES-256-GCM. +- Implemented `PayloadEncryptor` class with project-specific encryption keys. +- Added encrypted endpoint support for events and metrics (`/api/v1/events/encrypted`). +- Integrated automatic datetime serialization in encrypted payloads via custom JSON handler. +- Added encryption key verification during transport initialization. + +Technical Details (v1.1.0) +-------------------------- +- Encryption uses AES-256-GCM with 96-bit nonces and 128-bit authentication tags. +- Pydantic models are serialized using `.model_dump(mode="json")` before encryption. +- Custom `_default_json_handler` ensures datetime objects are properly ISO-formatted. + +___ + v1.0.2 (2025-09-12) ------------------- Enhancements (v1.0.2) ------------- +--------------------- - Added dynamic rule updated event type. ___ diff --git a/docs/versions/versions.json b/docs/versions/versions.json index 13dc8fa..05a3406 100644 --- a/docs/versions/versions.json +++ b/docs/versions/versions.json @@ -1,9 +1,10 @@ { + "1.1.0": "1.1.0", "1.0.2": "1.0.2", "1.0.1": "1.0.1", "1.0.0": "1.0.0", "0.1.1": "0.1.1", "0.1.0": "0.1.0", "0.0.1": "0.0.1", - "latest": "1.0.2" + "latest": "1.1.0" } \ No newline at end of file diff --git a/guard_agent/__init__.py b/guard_agent/__init__.py index 6ee062d..5abe9a5 100644 --- a/guard_agent/__init__.py +++ b/guard_agent/__init__.py @@ -34,7 +34,7 @@ validate_config, ) -__version__ = "1.0.2" +__version__ = "1.1.0" __all__ = [ # Main components diff --git a/guard_agent/encryption.py b/guard_agent/encryption.py new file mode 100644 index 0000000..1122822 --- /dev/null +++ b/guard_agent/encryption.py @@ -0,0 +1,210 @@ +import base64 +import json +import os +from datetime import datetime +from typing import Any + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class EncryptionError(Exception): + """Base exception for encryption-related errors.""" + + pass + + +def _default_json_handler(obj: Any) -> str: + """Handle non-serializable objects during JSON encoding.""" + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +class PayloadEncryptor: + """ + Encrypts telemetry payloads using project-specific encryption keys with AES-256-GCM. + + This class provides symmetric encryption using AES-256-GCM (Authenticated + Encryption with Associated Data) to protect telemetry data in transit + between the agent and the core backend. + + Security features: + - 256-bit keys for strong security + - Authenticated encryption (prevents tampering) + - Project-specific keys for isolation + - Automatic JSON serialization + - Base64 encoding for safe transmission + - 2-3x faster than CBC mode + - No padding oracle vulnerabilities + + Example: + >>> encryptor = PayloadEncryptor(project_key="your_key_here") + >>> data = {"events": [...], "metrics": [...]} + >>> encrypted = encryptor.encrypt(data) + >>> # Send encrypted to core + """ + + # GCM configuration + NONCE_SIZE = 12 # 96 bits (optimal for GCM) + TAG_SIZE = 16 # 128 bits (maximum security) + KEY_SIZE = 32 # 256 bits (AES-256) + + def __init__(self, project_key: str) -> None: + """ + Initialize the encryptor with a project encryption key. + + Args: + project_key: Base64-encoded 256-bit key from core backend + + Raises: + EncryptionError: If the project key is invalid + """ + if not project_key: + raise EncryptionError("Project key cannot be empty") + + try: + # Decode project key + key_bytes = base64.urlsafe_b64decode(project_key.encode()) + + # Validate key size + if len(key_bytes) != self.KEY_SIZE: + raise EncryptionError( + f"Invalid key size: {len(key_bytes)} bytes, " + f"expected {self.KEY_SIZE}" + ) + + self._cipher = AESGCM(key_bytes) + + except EncryptionError: + raise + except Exception as e: + raise EncryptionError(f"Invalid project key format: {e}") from e + + def encrypt(self, data: dict[str, Any], associated_data: str | None = None) -> str: + """ + Encrypt a telemetry payload with AES-256-GCM. + + The data is serialized to JSON (with deterministic key ordering), + encrypted using AES-256-GCM, and base64-encoded for transmission. + + Args: + data: Dictionary containing events and/or metrics + associated_data: Optional authenticated data (not encrypted) + + Returns: + Base64-encoded encrypted payload string + + Raises: + EncryptionError: If encryption fails + + Example: + >>> data = { + ... "events": [{"type": "rate_limit", "ip": "1.2.3.4"}], + ... "metrics": [{"type": "request_count", "value": 100}] + ... } + >>> encrypted = encryptor.encrypt(data) + """ + try: + # Serialize with deterministic ordering and datetime handling + json_data = json.dumps( + data, + separators=(",", ":"), + sort_keys=True, + default=_default_json_handler, + ) + + # Generate random nonce (MUST be unique per encryption) + nonce = os.urandom(self.NONCE_SIZE) + + # Prepare associated data + aad = associated_data.encode() if associated_data else None + + # Encrypt with authentication + encrypted = self._cipher.encrypt(nonce, json_data.encode(), aad) + + # Combine nonce + ciphertext + combined = nonce + encrypted + + # Base64 encode for transmission + return base64.urlsafe_b64encode(combined).decode() + + except Exception as e: + raise EncryptionError(f"Failed to encrypt payload: {e}") from e + + def decrypt( + self, encrypted_data: str, associated_data: str | None = None + ) -> dict[str, Any]: + """ + Decrypt an encrypted payload (primarily for testing). + + In normal operation, only the core backend decrypts payloads. + This method is provided for testing and validation purposes. + + Args: + encrypted_data: Base64-encoded encrypted payload + associated_data: Optional authenticated data + + Returns: + Decrypted dictionary + + Raises: + EncryptionError: If decryption fails or data is tampered + + Example: + >>> decrypted = encryptor.decrypt(encrypted_payload) + """ + try: + # Decode from base64 + combined = base64.urlsafe_b64decode(encrypted_data.encode()) + + # Extract nonce and ciphertext + nonce = combined[: self.NONCE_SIZE] + ciphertext = combined[self.NONCE_SIZE :] + + # Prepare associated data + aad = associated_data.encode() if associated_data else None + + # Decrypt with authentication verification + decrypted = self._cipher.decrypt(nonce, ciphertext, aad) + + # Parse JSON + return json.loads(decrypted.decode()) # type: ignore[no-any-return] + + except Exception as e: + raise EncryptionError("Invalid or tampered payload") from e + + def verify_key(self) -> bool: + """ + Verify that the encryption key is valid. + + Performs a quick encryption/decryption round-trip test. + + Returns: + True if key is valid, False otherwise + """ + try: + test_data = {"test": "verification"} + encrypted = self.encrypt(test_data) + decrypted = self.decrypt(encrypted) + return decrypted == test_data + except EncryptionError: + return False + + +def create_encryptor(project_key: str | None) -> PayloadEncryptor | None: + """ + Factory function to create an encryptor with AES-256-GCM. + + Args: + project_key: Optional project encryption key (256-bit, base64-encoded) + + Returns: + PayloadEncryptor instance if key is provided, None otherwise + + Raises: + EncryptionError: If key is provided but invalid + """ + if not project_key: + return None + + return PayloadEncryptor(project_key) diff --git a/guard_agent/models.py b/guard_agent/models.py index 85867bf..712a6be 100644 --- a/guard_agent/models.py +++ b/guard_agent/models.py @@ -42,6 +42,12 @@ class AgentConfig(BaseModel): default=1024, description="Maximum payload size to include in events (bytes)" ) + # Encryption + project_encryption_key: str | None = Field( + default=None, + description="Project-specific encryption key for secure telemetry transmission", + ) + @field_validator("endpoint") # type: ignore @classmethod def validate_endpoint(cls, v: str) -> str: diff --git a/guard_agent/transport.py b/guard_agent/transport.py index 2208e06..6c81d64 100644 --- a/guard_agent/transport.py +++ b/guard_agent/transport.py @@ -4,6 +4,7 @@ import httpx +from guard_agent.encryption import EncryptionError, PayloadEncryptor, create_encryptor from guard_agent.models import ( AgentConfig, AgentStatus, @@ -36,6 +37,11 @@ def __init__(self, config: AgentConfig): # HTTP client management self._client: httpx.AsyncClient | None = None + # Encryption support + self._encryptor: PayloadEncryptor | None = None + self._encryption_enabled = False + self._init_encryption() + # Reliability features self.circuit_breaker = CircuitBreaker( failure_threshold=5, recovery_timeout=60.0 @@ -50,6 +56,27 @@ def __init__(self, config: AgentConfig): self.requests_failed = 0 self.bytes_sent = 0 + def _init_encryption(self) -> None: + """Initialize encryption if configured.""" + if not self.config.project_encryption_key: + self.logger.info("Encryption not configured - using unencrypted transport") + return + + try: + self._encryptor = create_encryptor(self.config.project_encryption_key) + if self._encryptor and self._encryptor.verify_key(): + self._encryption_enabled = True + self.logger.info("Encryption enabled - using encrypted endpoint") + else: + self.logger.warning( + "Invalid encryption key - falling back to unencrypted" + ) + except EncryptionError as e: + self.logger.warning( + f"Encryption setup failed: {e} - using unencrypted transport" + ) + self._encryption_enabled = False + async def initialize(self) -> None: """Initialize HTTP client.""" if self._client and not self._client.is_closed: @@ -58,7 +85,7 @@ async def initialize(self) -> None: try: # Setup headers headers = { - "User-Agent": "FastAPI-Guard-Agent/1.0.2", + "User-Agent": "FastAPI-Guard-Agent/1.1.0", "Content-Type": "application/json", "Authorization": f"Bearer {self.config.api_key}", } @@ -245,7 +272,7 @@ async def _get_with_retry(self, endpoint: str) -> dict[str, Any] | None: async def _make_request( self, method: str, endpoint: str, data: dict[str, Any] | None ) -> dict[str, Any] | bool: - """Make HTTP request with proper error handling.""" + """Make HTTP request with proper error handling and optional encryption.""" if not self._client: await self.initialize() @@ -256,12 +283,58 @@ async def _make_request( try: if method == "POST" and data: - # Serialize data - json_data = await safe_json_serialize(data) - self.bytes_sent += len(json_data.encode("utf-8")) + # Check if this is an events or + # metrics endpoint and encryption is enabled + if self._encryption_enabled and endpoint in [ + "/api/v1/events", + "/api/v1/metrics", + ]: + if not self._encryptor: + raise EncryptionError("Encryptor not initialized") + + # Extract events/metrics for encryption and serialize to dicts + payload_to_encrypt = { + "events": [ + event.model_dump(mode="json") + if hasattr(event, "model_dump") + else event + for event in data.get("events", []) + ], + "metrics": [ + metric.model_dump(mode="json") + if hasattr(metric, "model_dump") + else metric + for metric in data.get("metrics", []) + ], + } + + # Encrypt the payload + encrypted_payload = self._encryptor.encrypt(payload_to_encrypt) + + # Create encrypted request + encrypted_data = { + "encrypted_payload": encrypted_payload, + "batch_id": data.get("batch_id"), + "agent_version": "1.1.0", + } + + # Use encrypted endpoint + encrypted_url = ( + f"{self.config.endpoint.rstrip('/')}/api/v1/events/encrypted" + ) - response = await self._client.post(url, content=json_data) - return await self._handle_response(response) + json_data = await safe_json_serialize(encrypted_data) + self.bytes_sent += len(json_data.encode("utf-8")) + + response = await self._client.post(encrypted_url, content=json_data) + return await self._handle_response(response) + else: + # Unencrypted request + json_data = await safe_json_serialize(data) + self.bytes_sent += len(json_data.encode("utf-8")) + + response = await self._client.post(url, content=json_data) + return await self._handle_response(response) elif method == "GET": response = await self._client.get(url) @@ -270,6 +343,9 @@ async def _make_request( else: raise ValueError(f"Unsupported method: {method}") + except EncryptionError as e: + self.logger.error(f"Encryption error for {method} {url}: {str(e)}") + raise except httpx.HTTPError as e: self.logger.error(f"HTTP client error for {method} {url}: {str(e)}") raise diff --git a/pyproject.toml b/pyproject.toml index cc69885..277f9d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fastapi_guard_agent" -version = "1.0.2" +version = "1.1.0" description = "Telemetry and monitoring agent for FastAPI Guard security middleware" authors = [ {name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"} @@ -23,6 +23,7 @@ classifiers = [ "Topic :: System :: Monitoring", ] dependencies = [ + "cryptography", "httpx", "fastapi", "fastapi-guard", @@ -121,6 +122,10 @@ follow_imports = "skip" module = "redis.*" follow_imports = "skip" +[[tool.mypy.overrides]] +module = "cryptography.*" +follow_imports = "skip" + [tool.pymarkdown.plugins.md007] # MD007 - Unordered list indentation (set to 2 spaces) enabled = true diff --git a/tests/test_encryption.py b/tests/test_encryption.py new file mode 100644 index 0000000..75e8dbf --- /dev/null +++ b/tests/test_encryption.py @@ -0,0 +1,376 @@ +import base64 +import json +from datetime import datetime, timezone +from typing import Any + +import pytest + +from guard_agent.encryption import ( + EncryptionError, + PayloadEncryptor, + _default_json_handler, + create_encryptor, +) + + +class TestPayloadEncryptor: + """Test suite for PayloadEncryptor class.""" + + @pytest.fixture + def valid_project_key(self) -> str: + """Generate a valid 256-bit project key.""" + # Generate 32 bytes (256 bits) for AES-256 + key_bytes = b"0" * 32 + return base64.urlsafe_b64encode(key_bytes).decode() + + @pytest.fixture + def encryptor(self, valid_project_key: str) -> PayloadEncryptor: + """Create a PayloadEncryptor instance with valid key.""" + return PayloadEncryptor(valid_project_key) + + def test_init_with_valid_key(self, valid_project_key: str) -> None: + """Test initialization with a valid key.""" + encryptor = PayloadEncryptor(valid_project_key) + assert encryptor is not None + assert encryptor._cipher is not None + + def test_init_with_empty_key(self) -> None: + """Test that empty key raises EncryptionError.""" + with pytest.raises(EncryptionError, match="Project key cannot be empty"): + PayloadEncryptor("") + + def test_init_with_invalid_base64(self) -> None: + """Test that invalid base64 raises EncryptionError.""" + # Base64 decoding actually succeeds, but produces wrong size + with pytest.raises(EncryptionError, match="Invalid key size"): + PayloadEncryptor("not-valid-base64!!!") + + def test_init_with_wrong_key_size(self) -> None: + """Test that wrong key size raises EncryptionError.""" + # 16 bytes instead of 32 + short_key = base64.urlsafe_b64encode(b"0" * 16).decode() + with pytest.raises(EncryptionError, match="Invalid key size"): + PayloadEncryptor(short_key) + + def test_encrypt_simple_payload(self, encryptor: PayloadEncryptor) -> None: + """Test encryption of a simple payload.""" + data = {"test": "value", "number": 42} + encrypted = encryptor.encrypt(data) + + # Verify it's a valid base64 string + assert isinstance(encrypted, str) + decoded = base64.urlsafe_b64decode(encrypted.encode()) + assert len(decoded) > 0 + + # Verify nonce is included (first 12 bytes) + assert len(decoded) >= 12 + + def test_decrypt_simple_payload(self, encryptor: PayloadEncryptor) -> None: + """Test decryption of a simple payload.""" + original_data = {"test": "value", "number": 42} + encrypted = encryptor.encrypt(original_data) + decrypted = encryptor.decrypt(encrypted) + + assert decrypted == original_data + + def test_encrypt_decrypt_round_trip(self, encryptor: PayloadEncryptor) -> None: + """Test full encryption/decryption round trip.""" + test_cases: list[dict[str, Any]] = [ + {"events": [], "metrics": []}, + { + "events": [ + {"type": "rate_limit", "ip": "1.2.3.4", "timestamp": "2024-01-01"} + ] + }, + {"metrics": [{"type": "request_count", "value": 100}]}, + { + "events": [{"type": "sql_injection", "payload": "' OR 1=1 --"}], + "metrics": [{"cpu": 50.5, "memory": 1024}], + }, + ] + + for data in test_cases: + encrypted = encryptor.encrypt(data) + decrypted = encryptor.decrypt(encrypted) + assert decrypted == data + + def test_encrypt_with_associated_data(self, encryptor: PayloadEncryptor) -> None: + """Test encryption with associated authenticated data.""" + data = {"test": "value"} + aad = "project-123" + + encrypted = encryptor.encrypt(data, associated_data=aad) + decrypted = encryptor.decrypt(encrypted, associated_data=aad) + + assert decrypted == data + + def test_decrypt_with_wrong_associated_data( + self, encryptor: PayloadEncryptor + ) -> None: + """Test that wrong AAD causes decryption failure.""" + data = {"test": "value"} + encrypted = encryptor.encrypt(data, associated_data="correct-aad") + + with pytest.raises(EncryptionError, match="Invalid or tampered payload"): + encryptor.decrypt(encrypted, associated_data="wrong-aad") + + def test_decrypt_tampered_payload(self, encryptor: PayloadEncryptor) -> None: + """Test that tampered payload is detected.""" + data = {"test": "value"} + encrypted = encryptor.encrypt(data) + + # Tamper with the encrypted data + encrypted_bytes = base64.urlsafe_b64decode(encrypted.encode()) + tampered = encrypted_bytes[:-1] + b"X" # Change last byte + tampered_str = base64.urlsafe_b64encode(tampered).decode() + + with pytest.raises(EncryptionError, match="Invalid or tampered payload"): + encryptor.decrypt(tampered_str) + + def test_decrypt_invalid_base64(self, encryptor: PayloadEncryptor) -> None: + """Test that invalid base64 is handled.""" + with pytest.raises(EncryptionError, match="Invalid or tampered payload"): + encryptor.decrypt("not-valid-base64!!!") + + def test_decrypt_too_short_payload(self, encryptor: PayloadEncryptor) -> None: + """Test that payload shorter than nonce is handled.""" + short_payload = base64.urlsafe_b64encode(b"short").decode() + with pytest.raises(EncryptionError, match="Invalid or tampered payload"): + encryptor.decrypt(short_payload) + + def test_encrypt_large_payload(self, encryptor: PayloadEncryptor) -> None: + """Test encryption of a large payload.""" + # Create a large payload with many events + large_data = { + "events": [ + {"id": i, "type": "test", "data": "x" * 100} for i in range(1000) + ] + } + + encrypted = encryptor.encrypt(large_data) + decrypted = encryptor.decrypt(encrypted) + + assert decrypted == large_data + + def test_encrypt_special_characters(self, encryptor: PayloadEncryptor) -> None: + """Test encryption of special characters and unicode.""" + data = { + "unicode": "Hello δΈ–η•Œ πŸ”", + "special": "!@#$%^&*()_+-=[]{}|;':\",./<>?", + "newlines": "line1\nline2\r\nline3", + } + + encrypted = encryptor.encrypt(data) + decrypted = encryptor.decrypt(encrypted) + + assert decrypted == data + + def test_verify_key_valid(self, encryptor: PayloadEncryptor) -> None: + """Test key verification with valid key.""" + assert encryptor.verify_key() is True + + def test_verify_key_determinism(self, valid_project_key: str) -> None: + """Test that encryption is non-deterministic (different nonces).""" + encryptor = PayloadEncryptor(valid_project_key) + data = {"test": "value"} + + # Encrypt same data twice + encrypted1 = encryptor.encrypt(data) + encrypted2 = encryptor.encrypt(data) + + # Should produce different ciphertexts (different nonces) + assert encrypted1 != encrypted2 + + # But both should decrypt to same value + assert encryptor.decrypt(encrypted1) == data + assert encryptor.decrypt(encrypted2) == data + + def test_json_serialization_determinism(self, encryptor: PayloadEncryptor) -> None: + """Test that JSON serialization is deterministic.""" + data1 = {"b": 2, "a": 1} + data2 = {"a": 1, "b": 2} + + encrypted1 = encryptor.encrypt(data1) + encrypted2 = encryptor.encrypt(data2) + + # Decrypt both + decrypted1 = encryptor.decrypt(encrypted1) + decrypted2 = encryptor.decrypt(encrypted2) + + # Both should produce same result (sorted keys) + assert decrypted1 == decrypted2 + + +class TestCreateEncryptor: + """Test suite for create_encryptor factory function.""" + + def test_create_with_valid_key(self) -> None: + """Test factory with valid key.""" + key = base64.urlsafe_b64encode(b"0" * 32).decode() + encryptor = create_encryptor(key) + + assert encryptor is not None + assert isinstance(encryptor, PayloadEncryptor) + + def test_create_with_none_key(self) -> None: + """Test factory with None returns None.""" + encryptor = create_encryptor(None) + assert encryptor is None + + def test_create_with_empty_key(self) -> None: + """Test factory with empty string returns None.""" + encryptor = create_encryptor("") + assert encryptor is None + + def test_create_with_invalid_key(self) -> None: + """Test factory with invalid key raises EncryptionError.""" + with pytest.raises(EncryptionError): + create_encryptor("invalid-key") + + +class TestEncryptionSecurity: + """Test suite for security properties of encryption.""" + + @pytest.fixture + def encryptor(self) -> PayloadEncryptor: + """Create encryptor with valid key.""" + key = base64.urlsafe_b64encode(b"1" * 32).decode() + return PayloadEncryptor(key) + + def test_different_keys_produce_different_ciphertexts(self) -> None: + """Test that different keys produce different ciphertexts.""" + key1 = base64.urlsafe_b64encode(b"1" * 32).decode() + key2 = base64.urlsafe_b64encode(b"2" * 32).decode() + + encryptor1 = PayloadEncryptor(key1) + encryptor2 = PayloadEncryptor(key2) + + data = {"test": "value"} + encrypted1 = encryptor1.encrypt(data) + encrypted2 = encryptor2.encrypt(data) + + # Different keys should produce different ciphertexts + assert encrypted1 != encrypted2 + + # Each can only decrypt its own + assert encryptor1.decrypt(encrypted1) == data + assert encryptor2.decrypt(encrypted2) == data + + with pytest.raises(EncryptionError): + encryptor1.decrypt(encrypted2) + + with pytest.raises(EncryptionError): + encryptor2.decrypt(encrypted1) + + def test_nonce_uniqueness(self, encryptor: PayloadEncryptor) -> None: + """Test that nonces are unique across encryptions.""" + data = {"test": "value"} + nonces = set() + + for _ in range(100): + encrypted = encryptor.encrypt(data) + encrypted_bytes = base64.urlsafe_b64decode(encrypted.encode()) + nonce = encrypted_bytes[:12] # First 12 bytes + nonces.add(nonce) + + # All nonces should be unique + assert len(nonces) == 100 + + def test_ciphertext_size(self, encryptor: PayloadEncryptor) -> None: + """Test ciphertext size is reasonable.""" + data = {"test": "value"} + plaintext = json.dumps(data, separators=(",", ":"), sort_keys=True) + encrypted = encryptor.encrypt(data) + encrypted_bytes = base64.urlsafe_b64decode(encrypted.encode()) + + # Size should be: nonce (12) + plaintext + tag (16) + expected_size = 12 + len(plaintext.encode()) + 16 + + assert len(encrypted_bytes) == expected_size + + +class TestDefaultJsonHandler: + """Test suite for _default_json_handler function.""" + + def test_datetime_serialization(self) -> None: + """Test that datetime objects are serialized to ISO format.""" + dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + result = _default_json_handler(dt) + assert result == "2024-01-01T12:00:00+00:00" + + def test_unsupported_type_raises_type_error(self) -> None: + """Test that unsupported types raise TypeError (line 20).""" + + class CustomObject: + pass + + obj = CustomObject() + with pytest.raises( + TypeError, match="Object of type CustomObject is not JSON serializable" + ): + _default_json_handler(obj) + + +class TestEncryptionEdgeCases: + """Test suite for edge cases.""" + + @pytest.fixture + def encryptor(self) -> PayloadEncryptor: + """Create encryptor with valid key.""" + key = base64.urlsafe_b64encode(b"0" * 32).decode() + return PayloadEncryptor(key) + + def test_encrypt_empty_dict(self, encryptor: PayloadEncryptor) -> None: + """Test encryption of empty dictionary.""" + data: dict[str, Any] = {} + encrypted = encryptor.encrypt(data) + decrypted = encryptor.decrypt(encrypted) + + assert decrypted == data + + def test_encrypt_nested_data(self, encryptor: PayloadEncryptor) -> None: + """Test encryption of deeply nested data.""" + data = { + "level1": { + "level2": {"level3": {"level4": {"value": "deep"}, "array": [1, 2, 3]}} + } + } + + encrypted = encryptor.encrypt(data) + decrypted = encryptor.decrypt(encrypted) + + assert decrypted == data + + def test_encrypt_with_null_values(self, encryptor: PayloadEncryptor) -> None: + """Test encryption with null/None values.""" + data = {"null_value": None, "empty_string": "", "zero": 0} + + encrypted = encryptor.encrypt(data) + decrypted = encryptor.decrypt(encrypted) + + assert decrypted == data + + def test_encrypt_json_serialization_error( + self, encryptor: PayloadEncryptor + ) -> None: + """Test that non-serializable data raises EncryptionError (line 118-119).""" + # Mock json.dumps to raise an error + import json + from unittest.mock import patch + + with patch.object(json, "dumps", side_effect=TypeError("not serializable")): + with pytest.raises(EncryptionError, match="Failed to encrypt payload"): + encryptor.encrypt({"test": "value"}) + + def test_verify_key_with_encryption_error(self) -> None: + """Test verify_key returns False when encryption fails.""" + key = base64.urlsafe_b64encode(b"0" * 32).decode() + encryptor = PayloadEncryptor(key) + + # Mock encrypt to raise EncryptionError + from unittest.mock import patch + + with patch.object( + encryptor, "encrypt", side_effect=EncryptionError("test error") + ): + assert encryptor.verify_key() is False diff --git a/tests/test_transport.py b/tests/test_transport.py index fe97a3d..cafbbd6 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -858,3 +858,238 @@ async def test_transport_interface_compatibility( mock_transport.send_metrics.assert_called_once() mock_transport.fetch_dynamic_rules.assert_called_once() mock_transport.send_status.assert_called_once() + + +class TestHTTPTransportEncryption: + """Tests for HTTPTransport encryption functionality.""" + + @pytest.mark.asyncio + async def test_init_encryption_with_valid_key(self) -> None: + """Test encryption initialization with valid key.""" + import base64 + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + config = AgentConfig( + api_key="test_key", + endpoint="http://test.com", + project_id="test_project", + project_encryption_key=valid_key, + ) + + transport = HTTPTransport(config) + + assert transport._encryption_enabled is True + assert transport._encryptor is not None + + @pytest.mark.asyncio + async def test_init_encryption_with_invalid_key(self) -> None: + """Test encryption initialization with invalid key.""" + config = AgentConfig( + api_key="test_key", + endpoint="http://test.com", + project_id="test_project", + project_encryption_key="invalid_key", + ) + + transport = HTTPTransport(config) + + # Should fallback to unencrypted due to invalid key + assert transport._encryption_enabled is False + assert transport._encryptor is None + + @pytest.mark.asyncio + async def test_init_encryption_with_verify_key_failure(self) -> None: + """Test encryption init when verify_key returns False.""" + import base64 + from unittest.mock import patch + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + config = AgentConfig( + api_key="test_key", + endpoint="http://test.com", + project_id="test_project", + project_encryption_key=valid_key, + ) + + with patch( + "guard_agent.encryption.PayloadEncryptor.verify_key", return_value=False + ): + transport = HTTPTransport(config) + + # Should fallback to unencrypted when verify_key fails + assert transport._encryption_enabled is False + # Encryptor is created but encryption is disabled + assert transport._encryptor is not None + + @pytest.mark.asyncio + async def test_init_encryption_with_encryptor_creation_error(self) -> None: + """Test encryption init when encryptor creation raises error.""" + from unittest.mock import patch + + from guard_agent.encryption import EncryptionError + + config = AgentConfig( + api_key="test_key", + endpoint="http://test.com", + project_id="test_project", + project_encryption_key="some_key", + ) + + with patch( + "guard_agent.transport.create_encryptor", + side_effect=EncryptionError("Test error"), + ): + transport = HTTPTransport(config) + + # Should fallback to unencrypted due to error + assert transport._encryption_enabled is False + + @pytest.mark.asyncio + async def test_make_request_encrypted_events_endpoint( + self, agent_config: AgentConfig, mock_client: AsyncMock + ) -> None: + """Test encrypted request for events endpoint.""" + import base64 + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + agent_config.project_encryption_key = valid_key + + transport = HTTPTransport(agent_config) + transport._client = mock_client + + # Configure successful response + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={"status": "ok"}) + mock_client.post.return_value = mock_response + + data = { + "events": [{"event_type": "test"}], + "metrics": [], + "batch_id": "test_123", + } + + result = await transport._make_request("POST", "/api/v1/events", data) + + assert result == {"status": "ok"} + # Verify POST was called to encrypted endpoint + assert mock_client.post.call_count == 1 + call_args = mock_client.post.call_args + assert "/api/v1/events/encrypted" in str(call_args[0][0]) + + @pytest.mark.asyncio + async def test_make_request_encrypted_metrics_endpoint( + self, agent_config: AgentConfig, mock_client: AsyncMock + ) -> None: + """Test encrypted request for metrics endpoint.""" + import base64 + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + agent_config.project_encryption_key = valid_key + + transport = HTTPTransport(agent_config) + transport._client = mock_client + + # Configure successful response + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={"status": "ok"}) + mock_client.post.return_value = mock_response + + data = { + "events": [], + "metrics": [{"metric_type": "test"}], + "batch_id": "test_123", + } + + result = await transport._make_request("POST", "/api/v1/metrics", data) + + assert result == {"status": "ok"} + # Verify POST was called to encrypted endpoint + assert mock_client.post.call_count == 1 + + @pytest.mark.asyncio + async def test_make_request_encrypted_without_encryptor( + self, agent_config: AgentConfig, mock_client: AsyncMock + ) -> None: + """Test encrypted endpoint when encryptor not initialized.""" + import base64 + + from guard_agent.encryption import EncryptionError + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + agent_config.project_encryption_key = valid_key + + transport = HTTPTransport(agent_config) + transport._client = mock_client + transport._encryption_enabled = True + transport._encryptor = None # Force encryptor to None + + data = {"events": [], "metrics": [], "batch_id": "test"} + + with pytest.raises(EncryptionError, match="Encryptor not initialized"): + await transport._make_request("POST", "/api/v1/events", data) + + @pytest.mark.asyncio + async def test_make_request_encryption_error_handling( + self, agent_config: AgentConfig, mock_client: AsyncMock + ) -> None: + """Test EncryptionError handling during encryption.""" + import base64 + from unittest.mock import patch + + from guard_agent.encryption import EncryptionError + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + agent_config.project_encryption_key = valid_key + + transport = HTTPTransport(agent_config) + transport._client = mock_client + + data = {"events": [], "metrics": [], "batch_id": "test"} + + # Mock encryptor to raise error + with patch.object( + transport._encryptor, "encrypt", side_effect=EncryptionError("Test error") + ): + with pytest.raises(EncryptionError): + await transport._make_request("POST", "/api/v1/events", data) + + @pytest.mark.asyncio + async def test_send_events_with_encryption(self, mock_client: AsyncMock) -> None: + """Test end-to-end event sending with encryption enabled.""" + import base64 + + valid_key = base64.urlsafe_b64encode(b"0" * 32).decode() + config = AgentConfig( + api_key="test_key", + endpoint="http://test.com", + project_id="test_project", + project_encryption_key=valid_key, + ) + + transport = HTTPTransport(config) + transport._client = mock_client + + # Configure successful response + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value={"status": "ok"}) + mock_client.post.return_value = mock_response + + events = [ + SecurityEvent( + timestamp=datetime.now(timezone.utc), + event_type="ip_banned", + ip_address="192.168.1.1", + action_taken="banned", + reason="test", + ) + ] + + result = await transport.send_events(events) + + assert result is True + assert transport._encryption_enabled is True + # Verify encrypted endpoint was used + assert mock_client.post.call_count >= 1