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)
95 lines
2.6 KiB
Python
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")
|