Files
Aniworld/tests/security/test_encryption_security.py

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