feat(settings): S1 AES-GCM crypto + Settings API/UI (masked write-only secrets)

- backend/crypto.py: AES-GCM (cryptography.AESGCM); key=base64(SLAB_AES_KEY)
  must be exactly 32 bytes; value_enc=base64(nonce(12)||ct); DecryptError on tamper
- backend/settings.py: get_settings (secrets->{configured:bool}, woo_base_url env
  read-only, flags default false), update_settings (rejects woo_base_url SETTINGS-85,
  encrypts secrets, partial plain writes)
- backend/app.py: wire GET/PUT /api/v1/settings
- frontend/src/create/settings/Settings.svelte: masked form + partial PUT
- tests: crypto roundtrip/tamper, masked GET, encrypted PUT, reject woo_base_url,
  flags default, decrypt-fail->configured:false (TDD)

Verified live: secrets round-trip configured:true with valid key; woo_base_url write
rejected 400; no secret leak in GET. Gates: check_requirements + check_coverage exit 0;
pytest 13 passed. Deploy+build on ubudual5060 (never Windows).
This commit is contained in:
2026-09-19 15:48:42 -07:00
parent 0eb2b0f697
commit b3380f3848
8 changed files with 589 additions and 6 deletions
+52
View File
@@ -0,0 +1,52 @@
"""TDD red tests for S1 crypto (QUALITY-125, SETTINGS-20/25)."""
import base64
import os
import tempfile
import pytest
from backend import crypto
def _valid_key_b64() -> str:
# 32 raw bytes base64
raw = b"0123456789abcdef0123456789abcdef" # exactly 32
return base64.b64encode(raw).decode()
def test_crypto_roundtrip_encrypt_decrypt(monkeypatch):
"""Given: valid SLAB_AES_KEY; When: encrypt then decrypt; Then: original value (QUALITY-125)."""
key_b64 = _valid_key_b64()
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
plaintext = "my-secret-app-password-123"
ct_b64 = crypto.encrypt(plaintext)
assert isinstance(ct_b64, str)
recovered = crypto.decrypt(ct_b64)
assert recovered == plaintext
def test_crypto_decrypt_tampered_raises_clean_error(monkeypatch):
"""Given: valid key; When: tamper the blob (flip a byte); Then: raises detectable error, no crash, no raw leak."""
key_b64 = _valid_key_b64()
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
plaintext = "secret"
ct_b64 = crypto.encrypt(plaintext)
# tamper: flip last byte
blob = bytearray(base64.b64decode(ct_b64))
blob[-1] ^= 0xFF
tampered_b64 = base64.b64encode(blob).decode()
with pytest.raises(Exception) as exc: # will be specific like InvalidTag or our wrapper
crypto.decrypt(tampered_b64)
# must not contain the raw blob or secret
msg = str(exc.value).lower()
assert "secret" not in msg
assert plaintext not in msg
# detectable, e.g. contains "decrypt" or "tag" or "corrupt"
assert any(k in msg for k in ("decrypt", "tag", "invalid", "corrupt", "fail"))
def test_crypto_missing_key_raises(monkeypatch):
"""Given: no SLAB_AES_KEY; When: encrypt; Then: clear runtime error."""
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
crypto.encrypt("x")