82219fe5ad
- AES-GCM encrypt/decrypt module (app/security/aes.py):
- get_aes_key: decode base64 AES master key to 32 bytes
- encrypt_setting: AES-256-GCM with fresh 12-byte nonce per call, returns nonce||ciphertext
- decrypt_setting: decrypts ciphertext, raises AuthenticationError on tamper/wrong key
- Settings endpoint (app/api/settings.py):
- GET /api/v1/settings: returns SettingsView with decrypted values, secrets masked
(woo_app_password never returned, only woo_credentials_configured boolean)
- PUT /api/v1/settings: encrypts all setting values AES-GCM, stores in settings table
- Rejects woo_consumer_key/woo_consumer_secret fields
- Validates woo_create_status (draft|publish)
- App factory (app/main.py): stores settings on app.state for dependency injection
- Unit tests (tests/unit/test_aes.py): 18 tests covering AES-1 (round-trip), AES-2
(missing key refusal), FUND-17 (fresh nonce, tamper rejection), FUND-19
(no plaintext fallback)
Test results: 23/23 passed (18 new AES + 5 existing health tests)
197 lines
8.1 KiB
Python
197 lines
8.1 KiB
Python
"""AES-GCM settings encryption unit tests.
|
|
|
|
AES-1: Encrypt then decrypt round-trips for woo_wp_username and woo_app_password;
|
|
ciphertext never appears in GET settings.
|
|
AES-2: Missing SLAB_AES_KEY environment variable at startup: app refuses to start;
|
|
no fallback to plaintext.
|
|
|
|
FUND-17: Fresh nonce per encrypt; tampered ciphertext raises authentication failure.
|
|
FUND-18: Ciphertext never appears in any GET response.
|
|
FUND-19: Missing SLAB_AES_KEY refuses startup; no fallback to plaintext storage.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app.security.aes import (
|
|
AuthenticationError,
|
|
decrypt_setting,
|
|
encrypt_setting,
|
|
get_aes_key,
|
|
)
|
|
|
|
|
|
class TestAESRoundTrip:
|
|
"""AES-1: Encrypt then decrypt round-trip for woo_wp_username and woo_app_password."""
|
|
|
|
@pytest.fixture
|
|
def aes_key_bytes(self) -> bytes:
|
|
"""Test AES key (32 bytes)."""
|
|
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
|
|
|
def test_roundtrip_username(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Encrypt and decrypt woo_wp_username round-trips."""
|
|
plaintext = "slab-uploader"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
|
assert decrypted == plaintext
|
|
|
|
def test_roundtrip_password(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Encrypt and decrypt woo_app_password round-trips."""
|
|
plaintext = "W00rdP@ssw0rd!2026"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
|
assert decrypted == plaintext
|
|
|
|
def test_roundtrip_empty_string(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Empty string encrypts and decrypts correctly."""
|
|
plaintext = ""
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
|
assert decrypted == plaintext
|
|
|
|
def test_roundtrip_unicode(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Unicode plaintext round-trips."""
|
|
plaintext = "тест-ключ-пароль"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
|
assert decrypted == plaintext
|
|
|
|
def test_roundtrip_special_chars(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Special characters in plaintext round-trip."""
|
|
plaintext = '!@#$%^&*()_+-={}[]|:;<>?/~`'
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
|
assert decrypted == plaintext
|
|
|
|
def test_ciphertext_is_bytes(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: encrypt_setting returns bytes."""
|
|
plaintext = "test-password"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
assert isinstance(ciphertext, bytes)
|
|
|
|
def test_ciphertext_starts_with_nonce(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Ciphertext starts with 12-byte nonce."""
|
|
plaintext = "test-password"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
assert len(ciphertext) >= 12
|
|
nonce = ciphertext[:12]
|
|
assert len(nonce) == 12
|
|
|
|
def test_ciphertext_different_from_plaintext(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Ciphertext is not the plaintext."""
|
|
plaintext = "secret"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
assert ciphertext != plaintext.encode("utf-8")
|
|
|
|
def test_ciphertext_changes_each_call(self, aes_key_bytes: bytes) -> None:
|
|
"""AES-1: Ciphertext changes on each encrypt call (different nonce+IV)."""
|
|
plaintext = "same-plaintext"
|
|
ct1 = encrypt_setting(aes_key_bytes, plaintext)
|
|
ct2 = encrypt_setting(aes_key_bytes, plaintext)
|
|
assert ct1 != ct2
|
|
|
|
|
|
class TestFreshNonce:
|
|
"""FUND-17: Fresh nonce per encrypt; nonces differ between calls."""
|
|
|
|
@pytest.fixture
|
|
def aes_key_bytes(self) -> bytes:
|
|
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
|
|
|
def test_nonce_differs_across_encryptions(self, aes_key_bytes: bytes) -> None:
|
|
"""FUND-17: Encrypting the same plaintext twice produces different nonces."""
|
|
plaintext = "repeated-plaintext"
|
|
ct1 = encrypt_setting(aes_key_bytes, plaintext)
|
|
ct2 = encrypt_setting(aes_key_bytes, plaintext)
|
|
nonce1 = ct1[:12]
|
|
nonce2 = ct2[:12]
|
|
assert nonce1 != nonce2
|
|
|
|
def test_multiple_encryptions_all_unique_nonces(self, aes_key_bytes: bytes) -> None:
|
|
"""FUND-17: Multiple encryptions all have unique nonces."""
|
|
nonces = []
|
|
for _ in range(5):
|
|
ct = encrypt_setting(aes_key_bytes, "test")
|
|
nonces.append(ct[:12])
|
|
assert len(set(nonces)) == 5
|
|
|
|
|
|
class TestTamperedCiphertext:
|
|
"""FUND-17: Tampered ciphertext raises authentication failure."""
|
|
|
|
@pytest.fixture
|
|
def aes_key_bytes(self) -> bytes:
|
|
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
|
|
|
def test_tampered_body_raises_authentication_error(self, aes_key_bytes: bytes) -> None:
|
|
"""FUND-17: Tampering with ciphertext body raises AuthenticationError."""
|
|
plaintext = "tamper-me"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
tampered = bytearray(ciphertext)
|
|
# Flip a byte in the ciphertext portion (after nonce)
|
|
if len(tampered) > 12:
|
|
tampered[12] ^= 0xFF
|
|
else:
|
|
tampered[0] ^= 0xFF
|
|
tampered_ct = bytes(tampered)
|
|
|
|
with pytest.raises(AuthenticationError):
|
|
decrypt_setting(aes_key_bytes, tampered_ct)
|
|
|
|
def test_truncated_ciphertext_raises_error(self, aes_key_bytes: bytes) -> None:
|
|
"""FUND-17: Truncated ciphertext raises authentication error."""
|
|
plaintext = "truncate-me"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
truncated = ciphertext[:-4]
|
|
|
|
with pytest.raises(AuthenticationError):
|
|
decrypt_setting(aes_key_bytes, truncated)
|
|
|
|
def test_wrong_key_raises_error(self, aes_key_bytes: bytes) -> None:
|
|
"""FUND-17: Decrypting with wrong key raises authentication error."""
|
|
wrong_key = get_aes_key("d3JvbmdrZXlmb3J0ZXN0aW5nc3lzdGVtY2hldmkgdGhpc2tleWlzYmFk")
|
|
plaintext = "wrong-key-test"
|
|
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
|
|
|
with pytest.raises(AuthenticationError):
|
|
decrypt_setting(wrong_key, ciphertext)
|
|
|
|
|
|
class TestMissingKey:
|
|
"""AES-2: Missing SLAB_AES_KEY environment variable at startup."""
|
|
|
|
def test_missing_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""AES-2: App refuses to start when SLAB_AES_KEY is missing."""
|
|
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
|
|
from app.config import Settings
|
|
settings = Settings()
|
|
with pytest.raises(ValueError, match="SLAB_AES_KEY"):
|
|
settings.validate()
|
|
|
|
def test_too_short_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""AES-2: Too-short key refuses startup."""
|
|
monkeypatch.setenv("SLAB_AES_KEY", "d290") # "wot" = too short
|
|
from app.config import Settings
|
|
settings = Settings()
|
|
with pytest.raises(ValueError, match="at least 32 bytes"):
|
|
settings.validate()
|
|
|
|
def test_get_aes_key_returns_bytes(self) -> None:
|
|
"""AES-2: get_aes_key returns bytes from base64 env var."""
|
|
settings = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
|
assert isinstance(settings, bytes)
|
|
assert len(settings) == 32
|
|
|
|
|
|
class TestNoPlaintextFallback:
|
|
"""AES-2: No fallback to plaintext storage."""
|
|
|
|
def test_decrypt_returns_decrypted_value(self) -> None:
|
|
"""AES-2: No plaintext fallback; decrypt returns decrypted value, not raw storage."""
|
|
key = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
|
plaintext = "test-password"
|
|
ct = encrypt_setting(key, plaintext)
|
|
result = decrypt_setting(key, ct)
|
|
# Should return the original plaintext, not some fallback
|
|
assert result == plaintext
|