Files
astrololo/services/logic/tests/test_link_crypto.py
T
gitea ddb0acc218
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
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 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Merge origin/master do PRE-16: naprawa regresji strumienia postepu
Konflikt w logic_client.py::positions() rozwiazany biorac OBIE zmiany:
routing przez szyfrowane _post() (PRE-16) + dluzszy timeout takze dla tables
(LOG-23) — warunek (stations or tables).

Wazniejsza rzecz, ktora scalenie ujawnilo: okno postepu (#19/#22) i szyfrowanie
lacz (#21) powstaly na rownoleglych galeziach, ktore sie nie widzialy. Po
zejsciu razem strumien horoskopu szedl SUROWYM httpx, z pominieciem szyfrowania.
Przy wlaczonym LINK_ENCRYPTION_REQUIRED serwer odrzucalby to zadanie (400), a
nawet bez wymagania odpowiedz wracalaby jako nieczytelne ramki — okno postepu
przestaloby dzialac na produkcji.

Naprawa:
- nowy link_crypto.stream_lines(): strumieniowe POST przez szyfrowane lacze;
  pieczetuje zadanie i odszyfrowuje odpowiedz ramka po ramce, sklejajac bufor bo
  granice ramek nie pokrywaja sie z granicami linii NDJSON. Dostarczanie na zywo
  zachowane. Bez klucza — jak dotad (dev).
- horoscope_stream() w kliencie idzie teraz przez stream_lines zamiast surowego
  client.stream.
- fail-closed takze dla strumienia: bez klucza przy wymaganym szyfrowaniu klient
  nie wysyla NIC (wczesniej cialo — dane urodzenia — szloby w eter, dopiero potem
  serwer odmawial). Ujednolica kontrakt z call().

Weryfikacja e2e na prawdziwym uvicornie z podsluchem gniazda: z kluczem strumien
dziala (5 etapow + result, na zywo), na kablu ZERO tresci bazy (grep=0; jedyne
'horoscope' to sciezka URL w naglowku, ktory z zalozenia jest jawny); bez klucza
klient zatrzymuje sie przed wyslaniem.

Testy: +4 na stream_lines (round-trip, sciezka jawna, fail-closed serwera i
klienta), niezmiennik strukturalny rozszerzony o stream_lines jako droge w dol
(sprawdzone celowym zepsuciem — czerwienieje). Calosc: logika 234 passed /
1 skipped, prezentacja 25 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:10:28 +02:00

402 lines
17 KiB
Python

"""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")