Schlüssel-Proxy, Server-Anforderungen und gitleaks-Regel für API-Werk

- tools/ki-proxy: setzt API-Schlüssel aus der Windows-Anmeldeinformationsverwaltung ein,
  nur im Speicher, ohne Header-Protokoll; Tests gegen Platzhalter-Server
- docs/server-anforderungen.md für den ITM-Entwickler
- .gitleaks.toml erkennt zki_/zodl_/zocr_-Schlüssel
- CLAUDE.md, Plan, Tech-Stack: Schlüssel nie in .env, in der App verschlüsselt in der DB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Christoph
2026-10-05 08:25:30 +02:00
co-authored by Claude Opus 5.5
parent 6d6f8f8f27
commit ca09d23a78
9 changed files with 716 additions and 4 deletions
+65
View File
@@ -0,0 +1,65 @@
# Schlüssel-Proxy für die KI-API (nur Entwicklung)
Der Proxy setzt die API-Schlüssel von ITM in Anfragen an `https://api-werk.de` ein. Claude Code,
Tests und die App in Sail rufen den Proxy ohne Schlüssel auf und sehen ihn nie. Die Schlüssel
liegen in der Windows-Anmeldeinformationsverwaltung. Der Proxy liest sie beim Start und hält sie
nur im Arbeitsspeicher; er protokolliert Methode, Pfad, Status und Dauer, aber keine Header.
In Staging und Produktion gibt es keinen Proxy: Dort stehen die Schlüssel verschlüsselt in der
Datenbank (Verwaltung → Einstellungen).
## Einmalig: Schlüssel in den Windows-Tresor eintragen
Systemsteuerung → Anmeldeinformationsverwaltung → Windows-Anmeldeinformationen →
„Generische Anmeldeinformationen hinzufügen“:
| Internet- oder Netzwerkadresse | Benutzername | Kennwort |
|---|---|---|
| `ki-bauin/itm-ki` | `api` | KI-Schlüssel (`zki_…`) |
| `ki-bauin/dataloader` | `api` | OpenDataLoader-Schlüssel (`zodl_…`) |
| `ki-bauin/ocr` | `api` | OCR-Schlüssel (`zocr_…`) |
Schlüssel nie in ein Terminal, eine Datei oder den Chat mit Claude kopieren.
## Starten (nur du, in einem eigenen Terminal)
```bash
cd ~/code/ki-bauin
python3 tools/ki-proxy/ki_proxy.py --check # zeigt nur „gefunden“ / „FEHLT“, keine Werte
python3 tools/ki-proxy/ki_proxy.py # läuft, bis Strg+C
```
Nach dem Ändern eines Schlüssels den Proxy neu starten. Claude Code startet den Proxy nie selbst;
ein Hook in `~/.claude/settings.json` sperrt das zusätzlich.
## Adressen
| Von | Adresse |
|---|---|
| WSL (Claude Code, Skripte) | `http://127.0.0.1:8787` |
| App in Sail (Container) | `http://host.docker.internal:8787` |
Pfade wie bei api-werk.de: `/v1/ki/…`, `/v1/dataloader/…`, `/v1/ocr/…`. Andere Pfade lehnt der
Proxy ab. Über die Netzwerkkarte der WSL und aus dem LAN ist er nicht erreichbar.
```bash
curl -s http://127.0.0.1:8787/v1/ki/models
```
## Was der Proxy schützt und was nicht
- **Geschützt:** Der Schlüssel steht in keiner Datei, keiner Umgebungsvariablen und keinem
Startbefehl. Den Speicher des Proxys können andere Prozesse nicht lesen (`ptrace_scope=1`).
Ein Hook sperrt für Claude Code Windows-Programme (`powershell.exe`, `cmdkey.exe` usw.), das
Auslesen fremder Prozesse und das Starten des Proxys.
- **Nicht geschützt:** Das ist ein Schutz vor Versehen, kein Schutz vor Absicht. Jeder Prozess
auf deinem Rechner kann den Proxy aufrufen, solange er läuft. Und der Proxy schützt die
Schlüssel, nicht die Daten: Echte Kundendaten gehen trotzdem nicht an Claude.
## Tests
```bash
python3 -m unittest discover -s tools/ki-proxy
```
Die Tests laufen gegen einen Platzhalter-Server, ohne Tresor und ohne Netz.
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Schlüssel-Proxy für die KI-API von ITM (API-Werk) – nur für die Entwicklung.
Liest beim Start die API-Schlüssel aus der Windows-Anmeldeinformationsverwaltung,
hält sie nur im Arbeitsspeicher und setzt sie in weitergeleitete Anfragen ein.
Aufrufer (Claude Code, Tests, die App in Sail) schicken Anfragen ohne Schlüssel.
Der Proxy wird ausschließlich vom Entwickler in einem eigenen Terminal gestartet,
nie von Claude Code. Er gibt Schlüssel nie aus und protokolliert keine Header.
"""
from __future__ import annotations
import argparse
import base64
import http.client
import json
import logging
import shutil
import ssl
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable, Optional
from urllib.parse import urlsplit
# Pfadpräfix → Eintrag in der Windows-Anmeldeinformationsverwaltung
ROUTES: dict[str, str] = {
"/v1/ki/": "ki-bauin/itm-ki",
"/v1/dataloader/": "ki-bauin/dataloader",
"/v1/ocr/": "ki-bauin/ocr",
}
DEFAULT_UPSTREAM = "https://api-werk.de"
DEFAULT_PORT = 8787
DEFAULT_LISTEN = ("127.0.0.1", "172.17.0.1") # WSL selbst und die Docker-Brücke (Sail)
MAX_BODY_BYTES = 60 * 1024 * 1024 # Dateigrenze der API ist 50 MB
UPSTREAM_TIMEOUT = 300
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
log = logging.getLogger("ki-proxy")
# Liest generische Einträge über die Windows-API CredRead. Ausgabe je Eintrag:
# "<eintrag>\t<base64 des Werts in UTF-8>" oder "<eintrag>\t-", wenn er fehlt.
_POWERSHELL_TEMPLATE = r"""
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class KiBauinTresor {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL {
public int Flags; public int Type; public string TargetName; public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
public int AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
[DllImport("advapi32.dll")]
private static extern void CredFree(IntPtr credential);
public static string Read(string target) {
IntPtr p;
if (!CredRead(target, 1, 0, out p)) { return null; }
try {
CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
if (c.CredentialBlobSize == 0) { return ""; }
return Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2);
} finally { CredFree(p); }
}
}
'@
foreach ($t in @(__TARGETS__)) {
$v = [KiBauinTresor]::Read($t)
if ($v -eq $null) { [Console]::Out.WriteLine($t + "`t-") }
else { [Console]::Out.WriteLine($t + "`t" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($v.Trim()))) }
}
"""
def parse_tresor_output(output: str) -> dict[str, Optional[str]]:
"""Wertet die Ausgabe des PowerShell-Skripts aus."""
result: dict[str, Optional[str]] = {}
for line in output.splitlines():
line = line.strip()
if not line or "\t" not in line:
continue
target, value = line.split("\t", 1)
result[target] = None if value == "-" else base64.b64decode(value).decode("utf-8")
return result
def read_windows_credentials(targets: list[str]) -> dict[str, Optional[str]]:
"""Liest die angegebenen Einträge aus der Windows-Anmeldeinformationsverwaltung."""
powershell = shutil.which("powershell.exe")
if powershell is None:
raise RuntimeError("powershell.exe nicht gefunden – läuft das unter WSL mit Windows-Interop?")
quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in targets)
script = _POWERSHELL_TEMPLATE.replace("__TARGETS__", quoted)
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
completed = subprocess.run(
[powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
capture_output=True,
timeout=60,
)
if completed.returncode != 0:
# Die Meldung von PowerShell enthält keine Schlüssel; sie kommt in der Windows-Codepage.
detail = completed.stderr.decode("cp850", errors="replace").strip().splitlines()[:3]
raise RuntimeError("Lesen aus dem Windows-Tresor fehlgeschlagen: " + " | ".join(detail))
found = parse_tresor_output(completed.stdout.decode("ascii", errors="replace"))
return {t: found.get(t) for t in targets}
class _LimitedReader:
"""Liest genau `remaining` Bytes aus dem Eingangsstrom, damit der Request-Body gestreamt wird."""
def __init__(self, stream, length: int):
self._stream = stream
self._remaining = length
def read(self, size: int = -1) -> bytes:
if self._remaining <= 0:
return b""
if size < 0 or size > self._remaining:
size = self._remaining
chunk = self._stream.read(size)
self._remaining -= len(chunk)
return chunk
class ProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.0" # Antworten enden mit dem Schließen der Verbindung; einfach für Streaming
server_version = "ki-proxy"
server: "KiProxyServer"
def do_GET(self) -> None:
self._forward()
def do_POST(self) -> None:
self._forward()
def do_PUT(self) -> None:
self._forward()
def do_PATCH(self) -> None:
self._forward()
def do_DELETE(self) -> None:
self._forward()
def log_message(self, format: str, *args) -> None: # noqa: A002 – Standardausgabe abschalten
return
def _send_json(self, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _forward(self) -> None:
started = time.monotonic()
path_only = self.path.split("?", 1)[0]
target = next((t for prefix, t in ROUTES.items() if path_only.startswith(prefix)), None)
status = 0
sent = 0
try:
if target is None:
status = 404
self._send_json(404, {"error": "Unbekannter Pfad. Erlaubt: " + ", ".join(ROUTES)})
return
key = self.server.keys.get(target)
if not key:
status = 503
self._send_json(503, {"error": f"Schlüssel '{target}' fehlt im Windows-Tresor. Proxy nach dem Eintragen neu starten."})
return
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
status = 411
self._send_json(411, {"error": "Request-Body mit Content-Length senden, nicht chunked."})
return
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY_BYTES:
status = 413
self._send_json(413, {"error": "Request zu groß für den Proxy."})
return
headers = {
name: value
for name, value in self.headers.items()
if name.lower() not in HOP_BY_HOP | {"authorization", "host", "content-length"}
}
headers["Authorization"] = f"Bearer {key}"
if length or self.command in ("POST", "PUT", "PATCH"):
headers["Content-Length"] = str(length)
body = _LimitedReader(self.rfile, length) if length else None
conn = self.server.connect_upstream()
try:
conn.request(self.command, self.server.upstream_base_path + self.path, body=body, headers=headers)
response = conn.getresponse()
status = response.status
self.send_response(response.status, response.reason)
for name, value in response.getheaders():
if name.lower() not in HOP_BY_HOP | {"server", "date"}:
self.send_header(name, value)
self.end_headers()
while True:
chunk = response.read1(65536)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
sent += len(chunk)
finally:
conn.close()
except (OSError, http.client.HTTPException) as exc:
if status == 0:
status = 502
try:
self._send_json(502, {"error": f"KI-API nicht erreichbar ({type(exc).__name__})."})
except OSError:
pass
finally:
log.info("%s %s → %s, %d Bytes, %.0f ms", self.command, path_only, status or "-", sent, (time.monotonic() - started) * 1000)
class KiProxyServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address: tuple[str, int], keys: dict[str, Optional[str]], upstream: str):
super().__init__(address, ProxyHandler)
self.keys = keys
parts = urlsplit(upstream)
self.upstream_scheme = parts.scheme
self.upstream_host = parts.hostname or ""
self.upstream_port = parts.port or (443 if parts.scheme == "https" else 80)
self.upstream_base_path = parts.path.rstrip("/")
self._ssl_context = ssl.create_default_context() if parts.scheme == "https" else None
def connect_upstream(self) -> http.client.HTTPConnection:
if self.upstream_scheme == "https":
return http.client.HTTPSConnection(self.upstream_host, self.upstream_port, timeout=UPSTREAM_TIMEOUT, context=self._ssl_context)
return http.client.HTTPConnection(self.upstream_host, self.upstream_port, timeout=UPSTREAM_TIMEOUT)
def start_servers(hosts: list[str], port: int, keys: dict[str, Optional[str]], upstream: str) -> list[KiProxyServer]:
servers = []
for host in hosts:
try:
server = KiProxyServer((host, port), keys, upstream)
except OSError as exc:
log.warning("Kann nicht auf %s:%d lauschen (%s) – übersprungen.", host, port, exc.strerror)
continue
threading.Thread(target=server.serve_forever, daemon=True).start()
servers.append(server)
return servers
def main(argv: Optional[list[str]] = None, reader: Callable[[list[str]], dict[str, Optional[str]]] = read_windows_credentials) -> int:
parser = argparse.ArgumentParser(description="Schlüssel-Proxy für die KI-API von ITM (nur Entwicklung).")
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--listen", default=",".join(DEFAULT_LISTEN), help="Adressen, durch Komma getrennt")
parser.add_argument("--upstream", default=DEFAULT_UPSTREAM)
parser.add_argument("--check", action="store_true", help="Nur prüfen, welche Schlüssel im Tresor stehen, dann beenden")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
keys = reader(list(ROUTES.values()))
for target in ROUTES.values():
log.info("%-22s %s", target, "gefunden" if keys.get(target) else "FEHLT")
if args.check:
return 0 if all(keys.values()) else 1
hosts = [h.strip() for h in args.listen.split(",") if h.strip()]
servers = start_servers(hosts, args.port, keys, args.upstream)
if not servers:
log.error("Keine Adresse verfügbar, Proxy beendet.")
return 1
log.info("Proxy läuft auf %s → %s (Strg+C beendet)", ", ".join(f"{s.server_address[0]}:{s.server_address[1]}" for s in servers), args.upstream)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
log.info("Proxy beendet.")
finally:
for server in servers:
server.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())
+189
View File
@@ -0,0 +1,189 @@
"""Tests für den Schlüssel-Proxy. Ausführen: python3 -m unittest discover -s tools/ki-proxy"""
from __future__ import annotations
import base64
import http.client
import json
import logging
import os
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(__file__))
import ki_proxy # noqa: E402
KEYS = {"ki-bauin/itm-ki": "test-ki-schluessel", "ki-bauin/dataloader": "test-dl-schluessel", "ki-bauin/ocr": None}
class FakeUpstream(BaseHTTPRequestHandler):
"""Platzhalter für api-werk.de: merkt sich die letzte Anfrage."""
protocol_version = "HTTP/1.1"
last: dict = {}
release_stream = threading.Event()
def log_message(self, format, *args): # noqa: A002
return
def _record(self) -> bytes:
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
FakeUpstream.last = {"method": self.command, "path": self.path, "headers": dict(self.headers.items()), "body": body}
return body
def do_GET(self):
self._record()
if self.path.startswith("/v1/ki/stream"):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self._chunk(b"data: erster\n\n")
FakeUpstream.release_stream.wait(5)
self._chunk(b"data: [DONE]\n\n")
self.wfile.write(b"0\r\n\r\n")
return
status = 409 if self.path.startswith("/v1/dataloader/jobs/") else 200
payload = json.dumps({"data": [{"id": "chat"}, {"id": "embed"}]}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_POST(self):
body = self._record()
payload = json.dumps({"job_id": "abc", "bytes": len(body)}).encode()
self.send_response(202)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _chunk(self, data: bytes) -> None:
self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n")
self.wfile.flush()
class ProxyTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.upstream = ThreadingHTTPServer(("127.0.0.1", 0), FakeUpstream)
threading.Thread(target=cls.upstream.serve_forever, daemon=True).start()
upstream_url = f"http://127.0.0.1:{cls.upstream.server_address[1]}"
cls.proxy = ki_proxy.start_servers(["127.0.0.1"], 0, dict(KEYS), upstream_url)[0]
cls.port = cls.proxy.server_address[1]
@classmethod
def tearDownClass(cls):
cls.proxy.shutdown()
cls.upstream.shutdown()
def setUp(self):
FakeUpstream.last = {}
FakeUpstream.release_stream.clear()
self.logs = []
handler = logging.Handler()
handler.emit = lambda record: self.logs.append(record.getMessage())
ki_proxy.log.addHandler(handler)
ki_proxy.log.setLevel(logging.INFO)
self.addCleanup(ki_proxy.log.removeHandler, handler)
def request(self, method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.request(method, path, body=body, headers=headers or {})
return conn, conn.getresponse()
def test_setzt_den_schluessel_der_route_ein(self):
conn, response = self.request("GET", "/v1/ki/models")
self.assertEqual(200, response.status)
self.assertEqual(["chat", "embed"], [m["id"] for m in json.loads(response.read())["data"]])
self.assertEqual("Bearer test-ki-schluessel", FakeUpstream.last["headers"]["Authorization"])
conn.close()
def test_ueberschreibt_mitgeschickte_authorization(self):
conn, response = self.request("GET", "/v1/ki/models", headers={"Authorization": "Bearer fremd"})
response.read()
self.assertEqual("Bearer test-ki-schluessel", FakeUpstream.last["headers"]["Authorization"])
conn.close()
def test_upload_kommt_unveraendert_an_mit_eigenem_schluessel(self):
body = os.urandom(300_000)
headers = {"Content-Type": "multipart/form-data; boundary=x"}
conn, response = self.request("POST", "/v1/dataloader/jobs?x=1", body=body, headers=headers)
self.assertEqual(202, response.status)
self.assertEqual(len(body), json.loads(response.read())["bytes"])
self.assertEqual(body, FakeUpstream.last["body"])
self.assertEqual("/v1/dataloader/jobs?x=1", FakeUpstream.last["path"])
self.assertEqual("Bearer test-dl-schluessel", FakeUpstream.last["headers"]["Authorization"])
conn.close()
def test_reicht_fehlerstatus_durch(self):
conn, response = self.request("GET", "/v1/dataloader/jobs/abc/result?format=markdown")
self.assertEqual(409, response.status)
response.read()
conn.close()
def test_fehlender_schluessel_ergibt_503_ohne_weiterleitung(self):
conn, response = self.request("GET", "/v1/ocr/jobs/abc")
self.assertEqual(503, response.status)
self.assertIn("ki-bauin/ocr", json.loads(response.read())["error"])
self.assertEqual({}, FakeUpstream.last)
conn.close()
def test_unbekannter_pfad_ergibt_404(self):
conn, response = self.request("GET", "/admin")
self.assertEqual(404, response.status)
response.read()
self.assertEqual({}, FakeUpstream.last)
conn.close()
def test_streaming_kommt_sofort_an(self):
conn, response = self.request("GET", "/v1/ki/stream")
self.assertEqual(200, response.status)
first = response.read1(1024)
self.assertIn(b"erster", first) # kommt an, bevor der Platzhalter den Rest freigibt
self.assertNotIn(b"[DONE]", first)
FakeUpstream.release_stream.set()
rest = response.read()
self.assertIn(b"[DONE]", rest)
conn.close()
def test_protokoll_enthaelt_keine_schluessel(self):
for path in ("/v1/ki/models", "/v1/ocr/x", "/unbekannt"):
conn, response = self.request("GET", path, headers={"Authorization": "Bearer fremd"})
response.read()
conn.close()
self.assertTrue(self.logs)
for line in self.logs:
for secret in ("test-ki-schluessel", "test-dl-schluessel", "fremd", "Bearer"):
self.assertNotIn(secret, line)
class TresorAusgabeTest(unittest.TestCase):
def test_wertet_gefundene_und_fehlende_eintraege_aus(self):
wert = base64.b64encode("zki_Äbc123".encode()).decode()
output = f"ki-bauin/itm-ki\t{wert}\r\nki-bauin/ocr\t-\r\n"
self.assertEqual({"ki-bauin/itm-ki": "zki_Äbc123", "ki-bauin/ocr": None}, ki_proxy.parse_tresor_output(output))
def test_check_meldet_fehlende_schluessel_ohne_werte_auszugeben(self):
logs = []
handler = logging.Handler()
handler.emit = lambda record: logs.append(record.getMessage())
ki_proxy.log.addHandler(handler)
try:
code = ki_proxy.main(["--check"], reader=lambda targets: dict(KEYS))
finally:
ki_proxy.log.removeHandler(handler)
self.assertEqual(1, code)
joined = "\n".join(logs)
self.assertIn("FEHLT", joined)
self.assertNotIn("test-ki-schluessel", joined)
if __name__ == "__main__":
unittest.main()