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:
grokbot_ty
2026-09-07 08:00:29 +00:00
parent e5514fe3bf
commit 82219fe5ad
5 changed files with 526 additions and 7 deletions
+196
View File
@@ -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