233 lines
9.6 KiB
Python
233 lines
9.6 KiB
Python
"""Security-focused tests for encryption module.
|
|
|
|
Tests cryptographic properties, key strength, secure storage,
|
|
and security edge cases for the ConfigEncryption system.
|
|
"""
|
|
|
|
import base64
|
|
import os
|
|
import stat
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
|
|
from src.infrastructure.security.config_encryption import ConfigEncryption
|
|
|
|
|
|
class TestKeyStrength:
|
|
"""Tests for encryption key strength and format."""
|
|
|
|
def test_key_is_valid_fernet_format(self, tmp_path: Path):
|
|
"""Generated key is a valid url-safe base64-encoded 32-byte key."""
|
|
key_file = tmp_path / "encryption.key"
|
|
ConfigEncryption(key_file=key_file)
|
|
key = key_file.read_bytes()
|
|
# Fernet key is urlsafe base64 of 32 bytes = 44 bytes encoded
|
|
decoded = base64.urlsafe_b64decode(key)
|
|
assert len(decoded) == 32, "Fernet key must decode to 32 bytes"
|
|
|
|
def test_key_is_random_not_predictable(self, tmp_path: Path):
|
|
"""Two generated keys are different (not using static seed)."""
|
|
key1_file = tmp_path / "key1.key"
|
|
key2_file = tmp_path / "key2.key"
|
|
ConfigEncryption(key_file=key1_file)
|
|
ConfigEncryption(key_file=key2_file)
|
|
assert key1_file.read_bytes() != key2_file.read_bytes()
|
|
|
|
def test_key_length_sufficient(self, tmp_path: Path):
|
|
"""Key provides at least 128-bit security (Fernet uses AES-128-CBC)."""
|
|
key_file = tmp_path / "encryption.key"
|
|
ConfigEncryption(key_file=key_file)
|
|
key = key_file.read_bytes()
|
|
decoded = base64.urlsafe_b64decode(key)
|
|
# Fernet key = 16 bytes signing key + 16 bytes encryption key
|
|
assert len(decoded) >= 16, "Key must provide at least 128-bit security"
|
|
|
|
|
|
class TestSecureKeyStorage:
|
|
"""Tests for secure key file storage."""
|
|
|
|
def test_key_file_permissions_restrictive(self, tmp_path: Path):
|
|
"""Key file permissions are set to owner read/write only (0o600)."""
|
|
key_file = tmp_path / "encryption.key"
|
|
ConfigEncryption(key_file=key_file)
|
|
mode = os.stat(key_file).st_mode & 0o777
|
|
assert mode == 0o600, f"Key file mode should be 0o600, got {oct(mode)}"
|
|
|
|
def test_key_file_not_world_readable(self, tmp_path: Path):
|
|
"""Key file has no world-readable permission bits."""
|
|
key_file = tmp_path / "encryption.key"
|
|
ConfigEncryption(key_file=key_file)
|
|
mode = os.stat(key_file).st_mode
|
|
assert not (mode & stat.S_IROTH), "Key file must not be world-readable"
|
|
assert not (mode & stat.S_IWOTH), "Key file must not be world-writable"
|
|
|
|
def test_key_file_not_group_accessible(self, tmp_path: Path):
|
|
"""Key file has no group permission bits."""
|
|
key_file = tmp_path / "encryption.key"
|
|
ConfigEncryption(key_file=key_file)
|
|
mode = os.stat(key_file).st_mode
|
|
assert not (mode & stat.S_IRGRP), "Key file must not be group-readable"
|
|
assert not (mode & stat.S_IWGRP), "Key file must not be group-writable"
|
|
|
|
def test_key_backup_created_on_rotation(self, tmp_path: Path):
|
|
"""Key rotation creates a backup that preserves the old key."""
|
|
key_file = tmp_path / "encryption.key"
|
|
enc = ConfigEncryption(key_file=key_file)
|
|
old_key = key_file.read_bytes()
|
|
enc.rotate_key()
|
|
backup = tmp_path / "encryption.key.bak"
|
|
assert backup.exists(), "Backup should exist after rotation"
|
|
assert backup.read_bytes() == old_key
|
|
|
|
|
|
class TestEncryptedDataFormat:
|
|
"""Tests for encrypted data format validation."""
|
|
|
|
@pytest.fixture
|
|
def encryption(self, tmp_path: Path) -> ConfigEncryption:
|
|
"""Create a ConfigEncryption instance."""
|
|
return ConfigEncryption(key_file=tmp_path / "encryption.key")
|
|
|
|
def test_encrypted_value_is_base64(self, encryption: ConfigEncryption):
|
|
"""Encrypted output is valid base64 (outer encoding)."""
|
|
encrypted = encryption.encrypt_value("test_value")
|
|
# Should not raise
|
|
decoded = base64.b64decode(encrypted)
|
|
assert len(decoded) > 0
|
|
|
|
def test_encrypted_value_contains_no_plaintext(
|
|
self, encryption: ConfigEncryption
|
|
):
|
|
"""Encrypted output doesn't contain the original plaintext."""
|
|
plaintext = "super_secret_password_123"
|
|
encrypted = encryption.encrypt_value(plaintext)
|
|
assert plaintext not in encrypted
|
|
# Also check decoded bytes
|
|
decoded = base64.b64decode(encrypted)
|
|
assert plaintext.encode() not in decoded
|
|
|
|
def test_encrypted_config_structure(self, encryption: ConfigEncryption):
|
|
"""Encrypted config fields have proper structure markers."""
|
|
config = {"password": "test_pass"}
|
|
encrypted = encryption.encrypt_config(config)
|
|
entry = encrypted["password"]
|
|
assert isinstance(entry, dict)
|
|
assert "encrypted" in entry
|
|
assert "value" in entry
|
|
assert entry["encrypted"] is True
|
|
assert isinstance(entry["value"], str)
|
|
|
|
|
|
class TestDecryptionFailureSecurity:
|
|
"""Tests for secure behavior on decryption failures."""
|
|
|
|
def test_wrong_key_raises_exception(self, tmp_path: Path):
|
|
"""Decryption with wrong key raises an error (no silent failure)."""
|
|
enc1 = ConfigEncryption(key_file=tmp_path / "key1.key")
|
|
enc2 = ConfigEncryption(key_file=tmp_path / "key2.key")
|
|
encrypted = enc1.encrypt_value("secret")
|
|
with pytest.raises(Exception):
|
|
enc2.decrypt_value(encrypted)
|
|
|
|
def test_tampered_ciphertext_raises(self, tmp_path: Path):
|
|
"""Modified ciphertext is detected and causes decryption failure."""
|
|
enc = ConfigEncryption(key_file=tmp_path / "encryption.key")
|
|
encrypted = enc.encrypt_value("my_secret")
|
|
|
|
# Tamper with the encrypted data
|
|
decoded = base64.b64decode(encrypted)
|
|
tampered = bytearray(decoded)
|
|
if len(tampered) > 10:
|
|
tampered[10] ^= 0xFF # Flip bits
|
|
tampered_encoded = base64.b64encode(bytes(tampered)).decode("utf-8")
|
|
|
|
with pytest.raises(Exception):
|
|
enc.decrypt_value(tampered_encoded)
|
|
|
|
def test_truncated_ciphertext_raises(self, tmp_path: Path):
|
|
"""Truncated ciphertext causes decryption failure."""
|
|
enc = ConfigEncryption(key_file=tmp_path / "encryption.key")
|
|
encrypted = enc.encrypt_value("my_secret")
|
|
truncated = encrypted[:len(encrypted) // 2]
|
|
with pytest.raises(Exception):
|
|
enc.decrypt_value(truncated)
|
|
|
|
def test_empty_ciphertext_raises_value_error(self, tmp_path: Path):
|
|
"""Empty string input raises ValueError, not a cryptographic error."""
|
|
enc = ConfigEncryption(key_file=tmp_path / "encryption.key")
|
|
with pytest.raises(ValueError, match="Cannot decrypt empty value"):
|
|
enc.decrypt_value("")
|
|
|
|
|
|
class TestKeyCompromiseScenarios:
|
|
"""Tests for key compromise and rotation scenarios."""
|
|
|
|
def test_rotated_key_cannot_decrypt_old_data(self, tmp_path: Path):
|
|
"""After key rotation, old encrypted data cannot be decrypted."""
|
|
key_file = tmp_path / "encryption.key"
|
|
enc = ConfigEncryption(key_file=key_file)
|
|
encrypted = enc.encrypt_value("secret_data")
|
|
enc.rotate_key()
|
|
with pytest.raises(Exception):
|
|
enc.decrypt_value(encrypted)
|
|
|
|
def test_new_key_works_after_rotation(self, tmp_path: Path):
|
|
"""After rotation, newly encrypted data can be decrypted."""
|
|
key_file = tmp_path / "encryption.key"
|
|
enc = ConfigEncryption(key_file=key_file)
|
|
enc.rotate_key()
|
|
encrypted = enc.encrypt_value("new_secret")
|
|
assert enc.decrypt_value(encrypted) == "new_secret"
|
|
|
|
def test_backup_key_can_decrypt_old_data(self, tmp_path: Path):
|
|
"""Backup key from rotation can still decrypt old data."""
|
|
key_file = tmp_path / "encryption.key"
|
|
enc = ConfigEncryption(key_file=key_file)
|
|
encrypted = enc.encrypt_value("old_secret")
|
|
enc.rotate_key()
|
|
|
|
# Use backup key to decrypt
|
|
backup_key = (tmp_path / "encryption.key.bak").read_bytes()
|
|
old_cipher = Fernet(backup_key)
|
|
outer_decoded = base64.b64decode(encrypted)
|
|
decrypted = old_cipher.decrypt(outer_decoded).decode("utf-8")
|
|
assert decrypted == "old_secret"
|
|
|
|
|
|
class TestEnvironmentSecurity:
|
|
"""Tests for environment-level security considerations."""
|
|
|
|
def test_key_not_exposed_in_repr(self, tmp_path: Path):
|
|
"""ConfigEncryption repr/str doesn't expose the encryption key."""
|
|
key_file = tmp_path / "encryption.key"
|
|
enc = ConfigEncryption(key_file=key_file)
|
|
key_content = key_file.read_bytes().decode("utf-8")
|
|
obj_repr = repr(enc)
|
|
obj_str = str(enc)
|
|
assert key_content not in obj_repr
|
|
assert key_content not in obj_str
|
|
|
|
def test_encrypted_values_differ_for_same_input(self, tmp_path: Path):
|
|
"""Same input encrypted multiple times produces different outputs
|
|
(nonce/IV prevents ciphertext equality)."""
|
|
enc = ConfigEncryption(key_file=tmp_path / "encryption.key")
|
|
values = [enc.encrypt_value("same_password") for _ in range(5)]
|
|
# All 5 should be unique
|
|
assert len(set(values)) == 5
|
|
|
|
def test_encrypt_does_not_log_plaintext(self, tmp_path: Path):
|
|
"""Encryption operations use debug-level logging without plaintext."""
|
|
import logging
|
|
|
|
enc = ConfigEncryption(key_file=tmp_path / "encryption.key")
|
|
|
|
with patch.object(logging.getLogger("src.infrastructure.security.config_encryption"), "debug") as mock_debug:
|
|
enc.encrypt_value("super_secret_value")
|
|
for call in mock_debug.call_args_list:
|
|
args_str = str(call)
|
|
assert "super_secret_value" not in args_str
|