Dlaczego trzy tryby zamiast booleana
Binarne AUTH_ENFORCED=true/false zabija ścieżkę
migracji. W realnym wdrożeniu auth włączasz po
uruchomieniu agentów. Jeśli flip jest binarny, w dniu
enforce połowa agentów pada, bo ktoś zapomniał podpiąć
klienta podpisującego.
| Tryb | Zachowanie | Use case |
|---|---|---|
off | Middleware to no-op. Niepodpisane żądania przelatują wprost. Key-server nie jest pytany. | Pojedynczy user, jeden host, sieć domowa, smoke w CI. |
observe | Niepodpisane / nieprawidłowe żądania nadal przelatują, ale powód odrzucenia trafia do logu. | Migracja z niepodpisanego do podpisanego. Czytasz log, naprawiasz callerów, potem flipujesz. |
enforce | Podpisane żądania przelatują. Niepodpisane dostają 401; 503 jeśli key-server jest down. | Wieloagentowe wdrożenie, współdzielony host, produkcja. |
AUTH_MODE może się różnić między blokami w
trakcie migracji — consciousness-server może
siedzieć w enforce, podczas gdy
test-runner zostaje w observe dla
jednego upartego callera.
1. Wygeneruj jedną parę kluczy na agenta
Każdy agent dostaje własną parę ed25519. Uruchom na hoście, na którym agent będzie żył, żeby klucz prywatny nigdy nie podróżował:
# Jedna para kluczy na agenta. Uruchom na hoście, gdzie agent będzie żył.
ssh-keygen -t ed25519 -C "ecosystem-scribe" \
-f ~/.ssh/ecosystem-scribe -N ""
# Wynik: dwa pliki —
# ~/.ssh/ecosystem-scribe (klucz prywatny — zostaje na hoście agenta)
# ~/.ssh/ecosystem-scribe.pub (klucz publiczny — publikujesz na key-server) -N "" oznacza brak hasła. Jeśli agenci mają
startować bez nadzoru (kontener workera, jednostka
systemd), to realny wybór — granicą bezpieczeństwa staje
się system plików hosta zamiast prompta o hasło.
2. Bootstrap kluczy publicznych na key-server
Uwierzytelnianie polega na sprawdzeniu, czy
X-Agent: <name> w nagłówku mapuje się na
klucz publiczny, który key-server już zna. Mapowanie to po
prostu pliki na dysku:
# Na hoście z key-server: wrzuć klucz publiczny każdego agenta do
# katalogu agents/. Key-server podejmie go przy następnym żądaniu;
# restart nie jest potrzebny.
scp ~/.ssh/ecosystem-scribe.pub \
operator@key-server-host:/opt/ecosystem/key-server/keys/agents/scribe.pub
# Powtórz dla każdego agenta, który ma się uwierzytelniać.
Każdy plik .pub w
key-server/keys/agents/ definiuje agenta, który
może się uwierzytelnić. Bez bazy danych, bez panelu admina;
plik jest źródłem prawdy. Usunięcie pliku odbiera dostęp
przy następnym żądaniu.
3. Flip do observe i obserwuj log
Teraz włączasz auth bez psucia czegokolwiek:
# Przełącz każdy blok z off na observe. Restart, żeby env się załadowało.
AUTH_MODE=observe docker compose up -d
# Obserwuj log — każda linia to żądanie, które byłoby odrzucone w enforce.
tail -f deploy/volumes/*-logs/auth-observe.log
# Powody, które zobaczysz, i co naprawić:
# missing_headers caller jeszcze nie podpisuje
# unknown_agent podpisuje, ale kluczem niezbootstrapowanym
# bad_signature rozjazd protokołu w kodzie podpisującym callera
# timestamp_out_of_window dryf zegara callera (włącz NTP)
# nonce_replayed caller używa ponownie nonce'a (musi rotować) Iteruj, aż log siedzi czysty przez parę dni normalnego ruchu. Czysty oznacza: każdy wpis to znany, świadomie niepodpisany caller (probe healthchecka, lokalny skrypt do debugowania), nie żywy agent produkcyjny.
4. Flip do enforce
# Gdy auth-observe.log siedzi czysty przez parę dni, flipnij:
AUTH_MODE=enforce docker compose up -d
# Natychmiastowy rollback, gdyby coś poszło nie tak:
AUTH_MODE=off docker compose up -d
# Bez migracji stanu. Wygenerowane klucze pozostają ważne;
# system po prostu przestaje je sprawdzać.
Od tego momentu niepodpisani callerzy dostają twardy
401. Awaryjna furtka off jest o
jedną zmienną środowiskową — bez migracji stanu, bez
rewokacji kluczy, bez restartu zewnętrznego systemu.
Jak wygląda podpisywanie żądania w kodzie
Cortex i Claude Code podpisują wywołania CS, gdy są skonfigurowane. Dla własnych klientów protokół opisuje SIGNING-PROTOCOL.md. Implementacja w Pythonie jest krótka:
import time, json, secrets, base64
from nacl.signing import SigningKey
priv = SigningKey(open("/home/scribe/.ssh/ecosystem-scribe", "rb").read())
def sign_request(method, path, body_bytes=b""):
ts = str(int(time.time()))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).decode()
canonical = f"{method}\n{path}\n{ts}\n{nonce}\n".encode() + body_bytes
sig = priv.sign(canonical).signature
return {
"X-Agent": "scribe",
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": base64.urlsafe_b64encode(sig).decode(),
}
# I potem przy każdym żądaniu:
headers = sign_request("POST", "/api/notes", json.dumps(payload).encode())
requests.post(f"{CS}/api/notes", json=payload, headers=headers)
Stringiem kanonicznym jest
METHOD\n PATH\n TIMESTAMP\n NONCE\n BODY. Serwer
rekonstruuje go z nagłówków i request-line, potem weryfikuje
podpis ed25519 wobec klucza publicznego zmapowanego na
X-Agent. Replayy blokuje krótko żyjący cache
nonce'ów; dryf zegara powyżej ~60 s jest odrzucany.
Lista hardeningu
- Trzymaj port 3040 (key-server) z dala od publicznego internetu. Tylko loopback, VPN albo localhost-bind — port wydaje sekrety.
- Włącz IP allow-list na key-server nawet w zaufanym LAN-ie. Format CIDR; jedna linia na peera.
- Audytuj log audytowy —
deploy/volumes/key-server-logs/audit.jsonlto strukturalny JSONL. Tail-and-alert. - NTP na każdym hoście. Podpisane żądania są odrzucane, gdy zegar przekroczy okno.
- Rotuj klucze przy decommisioningu hosta: usuń
.pubna key-server, regeneruj na hoście agenta.
Pełen model zagrożeń: consciousness-server/SECURITY.md.