feat(bezpieczenstwo): szyfrowanie lacz miedzy warstwami AES-256-GCM (PRE-16)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m45s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m31s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 31s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
build / build (push) Successful in 4m15s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m41s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m36s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s

Warstwy rozmawialy ze soba jawnym tekstem wewnatrz klastra. Token
miedzywarstwowy (LOG-32) mowil KTO pyta, ale nie ukrywal CZEGO dotyczy
odpowiedz — a plyna nia surowe wiersze oryginalnych baz interpretacyjnych,
czyli rdzen produktu. Kto podsluchal ruch wewnatrz sieci (drugi pod, mirror
portu na switchu, zrzut z wezla), mial je w calosci.

Nowy modul link_crypto (kopia w kazdej z trzech uslug — nie maja wspolnej
biblioteki; test pilnuje, ze kopie sa identyczne):
- AES-256-GCM na ciele kazdego zadania i odpowiedzi. GCM daje poufnosc I
  uwierzytelnienie naraz, wiec nie ma wariantu „zaszyfrowane, ale podatne na
  modyfikacje".
- DWA niezalezne klucze, po jednym na pare rozmowcow (prezentacja-logika,
  logika-dane). Przejecie klucza prezentacji nie otwiera warstwy danych, gdzie
  leza cale bazy. Z kazdego klucza lacza HKDF wyprowadza osobne podklucze na
  kierunek, wiec zadanie i odpowiedz nigdy nie szyfruja sie tym samym kluczem.
- Do materialu uwierzytelnianego (AAD) wchodza kierunek, sciezka, znacznik
  czasu i numer ramki — wiec ramki nie da sie przekleic na inny endpoint,
  odtworzyc po czasie (okno MAX_SKEW) ani przestawic w strumieniu.
- Strona serwerowa to czyste ASGI: podmienia cialo zanim zobaczy je FastAPI
  i przepuszcza odpowiedz strumieniowa kawalek po kawalku (okno postepu dziala
  dalej). Fail-closed: przy ustawionym kluczu jawne zadanie dostaje odmowe.

Strumien postepu (okno pisania horoskopu) tez idzie przez szyfrowane lacze:
link_crypto.stream_lines() pieczetuje zadanie i odszyfrowuje odpowiedz ramka
po ramce (granice ramek != granice linii NDJSON), zachowujac dostarczanie na
zywo. Bez tego przy wlaczonym LINK_ENCRYPTION_REQUIRED serwer odrzucalby
strumien (400) i okno postepu przestaloby dzialac. Fail-closed obejmuje takze
strumien: klient bez klucza nie wysyla nic, zamiast puscic dane urodzenia
jawnym tekstem, zanim serwer zdazy odmowic.

Najgrozniejszy blad wyszedl z PODSLUCHU prawdziwego gniazda, nie z testow:
klient bez klucza wysylal pytanie jawnym tekstem, ZANIM serwer zdazyl odmowic.
Stad LINK_ENCRYPTION_REQUIRED: klient nie wysyla niczego, a usluga nie wstaje,
jesli klucza brak. Ta sama zasada co przy sekrecie logowania.

Klient prezentacji przepuszczony przez jeden punkt _post()/stream_lines: dopoki
kazda metoda skladala zadanie sama, dolozenie nowej znaczylo, ze latwo zapomniec
o tokenie albo kluczu (401 wyszedl juz raz dopiero na produkcji). Test
strukturalny: kazde wyjscie w dol musi miec i token, i klucz lacza (takze
strumien), a surowe httpx wolno tylko na sciezkach wyjetych spod szyfrowania.

Weryfikacja:
- testy link_crypto (round-trip, brak tresci baz w bajtach na sieci, odrzucenie
  obcego klucza / przestawionego bitu / przekleconej sciezki / przestawionej
  ramki / przeterminowanej koperty / urwanego strumienia; round-trip strumienia
  i fail-closed klienta i serwera dla strumienia),
- e2e na prawdziwym uvicornie z proxy zrzucajacym gniazdo: tresci baz brak na
  kablu w obie strony (grep=0), takze dla strumienia horoskopu; klucz jednej
  pary nie otwiera drugiej,
- calosc: logika 234 passed / 1 skipped, prezentacja 25 passed.

docs/wdrozenie-pre16.md: instrukcja krok po kroku z uzasadnieniem kolejnosci.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-07-22 23:52:18 +02:00
parent 23416cb1f9
commit 56fdf01d7d
13 changed files with 2379 additions and 49 deletions
+57 -10
View File
@@ -1,30 +1,47 @@
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32).
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32)
oraz klucz szyfrujący łącze (PRE-16).
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
strukturalnie: nie ma wywołania bez `headers=`.
strukturalnie.
Po dołożeniu szyfrowania ta sama klasa błędu ma gorszy objaw: wywołanie bez `link=`
nie wywala się widocznie, tylko po cichu wysyła treść JAWNYM tekstem. Dlatego dla
wywołań przez `link_crypto` wymagamy obu argumentów naraz.
"""
import ast
import pathlib
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
HTTP_VERBS = ("post", "get", "put", "patch", "delete")
# Wszystkie drogi w dół przez link_crypto — także strumień postępu (stream_lines),
# który dołożono po scaleniu okna postępu z szyfrowaniem. Każda musi nieść i token,
# i klucz łącza.
LINK_CALLS = ("call", "call_json", "stream_lines")
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool]]:
"""(nazwa_metody_http, linia, czy_ma_headers) dla każdego client.post/get."""
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool, bool]]:
"""(opis, linia, czy_ma_headers, czy_wymaga_i_ma_link) dla każdego wyjścia w dół."""
tree = ast.parse(path.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
if node.func.attr not in ("post", "get", "put", "patch", "delete"):
continue
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "client"):
continue
target = node.func.value
has_headers = any(kw.arg == "headers" for kw in node.keywords)
out.append((node.func.attr, node.lineno, has_headers))
has_link = any(kw.arg == "link" for kw in node.keywords)
if (node.func.attr in HTTP_VERBS
and isinstance(target, ast.Name) and target.id == "client"):
# Surowe wywołanie httpx — omija szyfrowanie, więc dopuszczalne tylko
# dla ścieżek wyjętych spod niego (patrz link_crypto.PUBLIC_PATHS).
out.append((f"client.{node.func.attr}()", node.lineno, has_headers, True))
elif (node.func.attr in LINK_CALLS
and isinstance(target, ast.Name) and target.id == "link_crypto"):
out.append((f"link_crypto.{node.func.attr}()", node.lineno, has_headers, has_link))
return out
@@ -35,13 +52,43 @@ def test_client_module_exists():
def test_every_outbound_call_sends_auth_header():
calls = _http_calls(CLIENT)
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
missing = [f"{CLIENT.name}:{line} client.{verb}()" for verb, line, ok in calls if not ok]
missing = [f"{CLIENT.name}:{line} {what}" for what, line, ok, _ in calls if not ok]
assert not missing, (
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
"ochronie): " + ", ".join(missing)
)
def test_every_outbound_call_passes_link_key():
"""Brak `link=` nie boli od razu — po prostu treść leci jawnym tekstem."""
calls = _http_calls(CLIENT)
missing = [f"{CLIENT.name}:{line} {what}" for what, line, _, ok in calls if not ok]
assert not missing, (
"Wywołania w dół bez klucza łącza — poszłyby NIEZASZYFROWANE: " + ", ".join(missing)
)
def test_raw_http_calls_only_on_paths_exempt_from_encryption():
"""Surowe `client.get/post` wolno wołać wyłącznie tam, gdzie szyfrowania nie ma
z założenia (`/health` dla sond k8s). Każde inne to obejście PRE-16."""
import re
from app import link_crypto
source = CLIENT.read_text(encoding="utf-8").splitlines()
offenders = []
for what, line, _, _ in _http_calls(CLIENT):
if not what.startswith("client."):
continue
url = re.search(r'f"\{self\.base_url\}([^"]*)"', source[line - 1])
if url is None or url.group(1) not in link_crypto.PUBLIC_PATHS:
offenders.append(f"{CLIENT.name}:{line} {what}")
assert not offenders, (
"Surowe wywołania HTTP poza ścieżkami wyjętymi spod szyfrowania: "
+ ", ".join(offenders)
)
def test_auth_headers_helper_is_lazy():
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
z pustym tokenem, gdyby zmienna pojawiła się później."""