feat(#28): Gate B [08/10]: AES-GCM settings round-trip (AES-1, AES-2)
- 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)
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user