Files
SlabUploader/backend/app/security/aes.py
T
grokbot_ty 82219fe5ad 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)
2026-09-07 08:00:29 +00:00

95 lines
2.6 KiB
Python

"""AES-GCM settings encryption and decryption.
Per AGENTS.md §4 and DATA-MODEL.md settings table:
- AES master key is SLAB_AES_KEY env var (32 bytes base64).
- value_enc = nonce(12) || ciphertext.
- Fresh nonce per encrypt.
- Tampered ciphertext raises authentication failure.
- No plaintext fallback.
"""
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class AuthenticationError(Exception):
"""Raised when ciphertext integrity check fails (tamper or wrong key)."""
pass
def get_aes_key(key_input: str | bytes) -> bytes:
"""Decode the AES master key to 32 bytes.
Accepts either a base64-encoded string or raw bytes.
Args:
key_input: Base64-encoded string or raw bytes (must be >= 32 bytes when decoded).
Returns:
32 raw bytes suitable for AES-256-GCM.
Raises:
ValueError: if key is too short or not valid base64.
"""
if isinstance(key_input, bytes):
key_bytes = key_input
else:
key_bytes = base64.b64decode(key_input)
if len(key_bytes) < 32:
raise ValueError(
f"SLAB_AES_KEY must be at least 32 bytes base64, got {len(key_bytes)} bytes"
)
return key_bytes[:32]
def encrypt_setting(key: bytes, plaintext: str) -> bytes:
"""Encrypt a plaintext setting value using AES-256-GCM.
Args:
key: 32-byte AES key (decoded from SLAB_AES_KEY).
plaintext: The secret value to encrypt (e.g. woo_app_password).
Returns:
Bytes of form nonce(12) || ciphertext. Each call generates a
fresh 12-byte nonce.
"""
import os
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return nonce + ct
def decrypt_setting(key: bytes, ciphertext: bytes) -> str:
"""Decrypt a setting ciphertext back to plaintext.
Args:
key: 32-byte AES key (decoded from SLAB_AES_KEY).
ciphertext: Bytes of form nonce(12) || ciphertext from encrypt_setting.
Returns:
Decrypted plaintext string.
Raises:
AuthenticationError: if ciphertext is tampered, truncated,
or decrypted with the wrong key.
"""
if len(ciphertext) < 12:
raise AuthenticationError("Ciphertext too short to contain nonce")
nonce = ciphertext[:12]
ct = ciphertext[12:]
try:
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ct, None)
except Exception as exc:
raise AuthenticationError(
f"Decryption failed: integrity check failed"
) from exc
return plaintext.decode("utf-8")