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
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:
@@ -0,0 +1,401 @@
|
||||
"""Szyfrowanie łącza między warstwami (PRE-16).
|
||||
|
||||
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
|
||||
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało" —
|
||||
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
|
||||
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
|
||||
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app import link_crypto
|
||||
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
|
||||
|
||||
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
|
||||
KEY_A = "11" * 32 # hex, 32 bajty
|
||||
KEY_B = "22" * 32
|
||||
|
||||
|
||||
class Payload(BaseModel):
|
||||
question: str
|
||||
|
||||
|
||||
def _app(link: Link | None) -> FastAPI:
|
||||
app = FastAPI()
|
||||
if link is not None:
|
||||
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/search")
|
||||
def search(payload: Payload):
|
||||
return {"echo": payload.question, "interpretation": SECRET}
|
||||
|
||||
@app.get("/catalog")
|
||||
def catalog():
|
||||
return {"models": ["a", "b"]}
|
||||
|
||||
@app.post("/stream")
|
||||
def stream():
|
||||
def lines():
|
||||
for i in range(4):
|
||||
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
|
||||
|
||||
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def link():
|
||||
return Link(link_crypto.parse_key(KEY_A))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- klucze
|
||||
|
||||
def test_key_accepts_hex_and_base64():
|
||||
import base64
|
||||
|
||||
raw = bytes(range(32))
|
||||
assert link_crypto.parse_key(raw.hex()) == raw
|
||||
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
|
||||
|
||||
|
||||
def test_key_of_wrong_length_is_rejected_loudly():
|
||||
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
|
||||
with pytest.raises(LinkError, match="32"):
|
||||
link_crypto.parse_key("aabb")
|
||||
|
||||
|
||||
def test_key_that_is_neither_hex_nor_base64_is_rejected():
|
||||
with pytest.raises(LinkError, match="hex"):
|
||||
link_crypto.parse_key("!!! to nie jest klucz !!!")
|
||||
|
||||
|
||||
def test_key_from_env_is_lazy(monkeypatch):
|
||||
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
|
||||
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
|
||||
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
|
||||
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
|
||||
|
||||
|
||||
def test_directions_use_different_subkeys(link):
|
||||
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
|
||||
stronę nie osłabia drugiej."""
|
||||
stamp = link_crypto.stamp_now()
|
||||
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
|
||||
with pytest.raises(LinkError):
|
||||
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
|
||||
|
||||
|
||||
# --------------------------------------------------------- podstawowy obieg
|
||||
|
||||
def test_round_trip_delivers_plaintext_to_the_app(link):
|
||||
with TestClient(_app(link)) as client:
|
||||
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||
payload={"question": "Saturn"}, link=link)
|
||||
assert got["echo"] == "Saturn"
|
||||
assert got["interpretation"] == SECRET
|
||||
|
||||
|
||||
def test_get_without_body_also_works(link):
|
||||
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
|
||||
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
|
||||
with TestClient(_app(link)) as client:
|
||||
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
|
||||
assert got == {"models": ["a", "b"]}
|
||||
|
||||
|
||||
def test_without_key_traffic_stays_plaintext(link):
|
||||
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
|
||||
with TestClient(_app(None)) as client:
|
||||
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||
payload={"question": "Saturn"}, link=None)
|
||||
assert got["interpretation"] == SECRET
|
||||
|
||||
|
||||
def test_health_stays_open_for_kubernetes_probes(link):
|
||||
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
|
||||
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
|
||||
with TestClient(_app(link)) as client:
|
||||
assert client.get("http://testserver/health").json() == {"status": "ok"}
|
||||
|
||||
|
||||
# ------------------------------------------- czy na kablu naprawdę nic nie widać
|
||||
|
||||
def _raw_exchange(client, link, payload):
|
||||
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
|
||||
stamp = link_crypto.stamp_now()
|
||||
body = link_crypto.frame_out(
|
||||
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
|
||||
response = client.request(
|
||||
"POST", "http://testserver/search", content=body,
|
||||
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||
link_crypto.HEADER_TS: stamp,
|
||||
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||
return body, response
|
||||
|
||||
|
||||
def test_request_bytes_do_not_contain_the_question(link):
|
||||
with TestClient(_app(link)) as client:
|
||||
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
|
||||
assert b"Saturn" not in body
|
||||
assert b"question" not in body
|
||||
|
||||
|
||||
def test_response_bytes_do_not_contain_the_interpretation(link):
|
||||
"""To jest właściwy powód istnienia całego modułu."""
|
||||
with TestClient(_app(link)) as client:
|
||||
_, response = _raw_exchange(client, link, {"question": "Saturn"})
|
||||
assert response.status_code == 200
|
||||
assert SECRET.encode() not in response.content
|
||||
assert b"interpretation" not in response.content
|
||||
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
|
||||
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
|
||||
|
||||
|
||||
# ------------------------------------------------------- drogi na skróty i ataki
|
||||
|
||||
def test_plaintext_request_is_refused_when_key_is_set(link):
|
||||
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
|
||||
powrotu do jawnego ruchu."""
|
||||
with TestClient(_app(link)) as client:
|
||||
response = client.post("http://testserver/search", json={"question": "Saturn"})
|
||||
assert response.status_code == 400
|
||||
assert SECRET.encode() not in response.content
|
||||
|
||||
|
||||
def test_foreign_key_cannot_read_the_link(link):
|
||||
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
|
||||
intruder = Link(link_crypto.parse_key(KEY_B))
|
||||
with TestClient(_app(link)) as client:
|
||||
stamp = link_crypto.stamp_now()
|
||||
body = link_crypto.frame_out(
|
||||
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
|
||||
response = client.request(
|
||||
"POST", "http://testserver/search", content=body,
|
||||
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||
link_crypto.HEADER_TS: stamp,
|
||||
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||
assert response.status_code == 400
|
||||
assert SECRET.encode() not in response.content
|
||||
|
||||
|
||||
def test_single_flipped_bit_is_rejected(link):
|
||||
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
|
||||
with TestClient(_app(link)) as client:
|
||||
stamp = link_crypto.stamp_now()
|
||||
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
|
||||
b'{"question":"x"}'))
|
||||
sealed[-1] ^= 0x01
|
||||
response = client.request(
|
||||
"POST", "http://testserver/search",
|
||||
content=link_crypto.frame_out(bytes(sealed)),
|
||||
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||
link_crypto.HEADER_TS: stamp,
|
||||
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_frame_cannot_be_replayed_against_another_endpoint(link):
|
||||
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
|
||||
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
|
||||
stamp = link_crypto.stamp_now()
|
||||
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
|
||||
with pytest.raises(LinkError):
|
||||
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
|
||||
|
||||
|
||||
def test_frames_cannot_be_reordered(link):
|
||||
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
|
||||
to błąd, a nie po cichu pomieszany horoskop."""
|
||||
stamp = link_crypto.stamp_now()
|
||||
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
|
||||
with pytest.raises(LinkError):
|
||||
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
|
||||
|
||||
|
||||
def test_stale_frame_is_refused(link, monkeypatch):
|
||||
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
|
||||
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
|
||||
with TestClient(_app(link)) as client:
|
||||
body = link_crypto.frame_out(
|
||||
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
|
||||
response = client.request(
|
||||
"POST", "http://testserver/search", content=body,
|
||||
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||
link_crypto.HEADER_TS: old,
|
||||
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_truncated_stream_is_an_error_not_silent_loss(link):
|
||||
stamp = link_crypto.stamp_now()
|
||||
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
|
||||
with pytest.raises(LinkError, match="urwana"):
|
||||
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
|
||||
|
||||
|
||||
def test_absurd_frame_length_does_not_allocate(link):
|
||||
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
|
||||
import struct
|
||||
|
||||
with pytest.raises(LinkError, match="rozmiar"):
|
||||
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------- odpowiedź strumieniowa
|
||||
|
||||
def test_streaming_response_survives_encryption(link):
|
||||
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
|
||||
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
|
||||
with TestClient(_app(link)) as client:
|
||||
stamp = link_crypto.stamp_now()
|
||||
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
|
||||
with client.stream("POST", "http://testserver/stream", content=body,
|
||||
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||
link_crypto.HEADER_TS: stamp,
|
||||
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
|
||||
chunks = list(link_crypto.open_response_stream(response, link))
|
||||
|
||||
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
|
||||
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||
assert all(s["note"] == SECRET for s in steps)
|
||||
|
||||
|
||||
def test_stream_lines_helper_round_trips_ndjson(link):
|
||||
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
||||
|
||||
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
||||
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
||||
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
||||
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
||||
tajny opis nie przecieka po drodze."""
|
||||
with TestClient(_app(link)) as client:
|
||||
lines = list(link_crypto.stream_lines(
|
||||
client, "http://testserver/stream", payload=None, link=link))
|
||||
steps = [json.loads(line) for line in lines]
|
||||
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||
assert all(s["note"] == SECRET for s in steps)
|
||||
|
||||
|
||||
def test_stream_lines_plaintext_path_still_works(link):
|
||||
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
||||
with TestClient(_app(None)) as client:
|
||||
lines = list(link_crypto.stream_lines(
|
||||
client, "http://testserver/stream", payload=None, link=None))
|
||||
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
||||
|
||||
|
||||
def test_stream_request_without_key_is_refused_when_required(link):
|
||||
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
||||
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
||||
with TestClient(_app(link)) as client:
|
||||
response = client.post("http://testserver/stream", json={})
|
||||
assert response.status_code == 400
|
||||
assert SECRET.encode() not in response.content
|
||||
|
||||
|
||||
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
||||
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
||||
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
||||
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||
sent = []
|
||||
|
||||
class Tripwire:
|
||||
def stream(self, *args, **kwargs):
|
||||
sent.append(args)
|
||||
raise AssertionError("strumień NIE powinien opuścić procesu")
|
||||
|
||||
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
||||
payload={"lat": 50.0}, link=None))
|
||||
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
||||
|
||||
|
||||
def test_incremental_unframing_handles_split_frames(link):
|
||||
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
||||
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
||||
stamp = link_crypto.stamp_now()
|
||||
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
|
||||
[b"raz", b"dwa", b"trzy"]))
|
||||
buffer = bytearray()
|
||||
opened, seq = [], 0
|
||||
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
|
||||
buffer += stream[i:i + 5]
|
||||
for frame in link_crypto.unframe_incremental(buffer):
|
||||
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
|
||||
seq += 1
|
||||
assert opened == [b"raz", b"dwa", b"trzy"]
|
||||
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
|
||||
|
||||
|
||||
# ----------------------------------------------- trzy kopie muszą być identyczne
|
||||
|
||||
def test_all_three_services_share_the_same_module():
|
||||
"""Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
|
||||
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
||||
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
||||
root = pathlib.Path(__file__).resolve().parents[3]
|
||||
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
||||
for svc in ("presentation", "logic", "data")}
|
||||
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
||||
assert not missing, f"brak modułu w warstwach: {missing}"
|
||||
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
||||
assert len(set(contents.values())) == 1, (
|
||||
"kopie link_crypto.py rozjechały się między warstwami: "
|
||||
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
|
||||
)
|
||||
|
||||
|
||||
def test_env_names_are_two_distinct_keys():
|
||||
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
|
||||
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
|
||||
|
||||
|
||||
# ------------------------------------------------- klient też musi być fail-closed
|
||||
#
|
||||
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
|
||||
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
|
||||
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
|
||||
|
||||
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
|
||||
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||
sent = []
|
||||
|
||||
class Tripwire:
|
||||
def request(self, *args, **kwargs):
|
||||
sent.append(args)
|
||||
raise AssertionError("żądanie NIE powinno opuścić procesu")
|
||||
|
||||
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||
link_crypto.call(Tripwire(), "POST", "http://logic/search",
|
||||
payload={"value": "[Sat"}, link=None)
|
||||
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
|
||||
|
||||
|
||||
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
|
||||
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
|
||||
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
|
||||
with TestClient(_app(None)) as client:
|
||||
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||
payload={"question": "Saturn"}, link=None)
|
||||
assert got["interpretation"] == SECRET
|
||||
|
||||
|
||||
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
|
||||
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
|
||||
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
|
||||
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
|
||||
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
|
||||
Reference in New Issue
Block a user