From 0e3951e401c9c793a22319bbacc82c05edb94631 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 24 Jun 2026 12:53:31 +0200 Subject: [PATCH] =?UTF-8?q?Bo=C3=AEte=20noire=20=C2=AB=20z=C3=A9ro=20excep?= =?UTF-8?q?tion=20=C2=BB=20:=20proxy=20de=20contr=C3=B4le=20chiffr=C3=A9?= =?UTF-8?q?=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e.go : handleE2EReq, proxy de contrôle chiffré de bout en bout. Même enveloppe que le chat ; le clair décrit {method,path,body}, redispatché dans le handler web local authentifié (httptest), réponse {status,body} rechiffrée. Garde-fous : chemins /api/* hors /api/e2e, recover(). - link.go : le tunnel refuse tout /api/* en clair ; seuls /api/e2e/chat et /api/e2e/req passent. Le relais ne voit plus jamais de clair. --- e2e.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ link.go | 20 +++++++++---- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/e2e.go b/e2e.go index 691e2cb..348f6c9 100644 --- a/e2e.go +++ b/e2e.go @@ -13,6 +13,7 @@ package main // Aucune dépendance externe : crypto/ecdh (X25519) + AES-GCM de la stdlib. import ( + "bytes" "crypto/aes" "crypto/cipher" "crypto/ecdh" @@ -22,7 +23,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -233,6 +236,96 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) { runChatStream(r.Context(), body, emit) } +// handleE2EReq : proxy de CONTRÔLE chiffré de bout en bout. Même enveloppe que le +// chat, mais le clair décrit un appel d'API interne {method, path, body}. On le +// redispatche dans le handler web LOCAL (inner, déjà authentifié), puis on chiffre +// la réponse {status, body}. Résultat : toute la gestion du serveur (presets, VRAM, +// skills, service…) transite par le relais SANS qu'il en voie le contenu. « Zéro +// exception » : le tunnel refuse tout /api/* en clair, seuls /api/e2e/* passent. +func handleE2EReq(w http.ResponseWriter, r *http.Request, inner http.Handler) { + var env struct{ Kid, SealedR, Iv, Ct string } + if err := json.NewDecoder(r.Body).Decode(&env); err != nil { + http.Error(w, "e2e: enveloppe invalide", 400) + return + } + var sealed []byte + if env.SealedR != "" { + sealed, _ = base64.StdEncoding.DecodeString(env.SealedR) + } + root, err := e2eResolveRoot(env.Kid, sealed) + if err != nil { + http.Error(w, "e2e: "+err.Error(), 400) + return + } + gcm, err := newGCM(chatKey(root)) + if err != nil { + http.Error(w, "e2e: clé", 500) + return + } + iv, _ := base64.StdEncoding.DecodeString(env.Iv) + ct, _ := base64.StdEncoding.DecodeString(env.Ct) + plain, err := gcm.Open(nil, iv, ct, nil) + if err != nil { + http.Error(w, "e2e: déchiffrement", 400) + return + } + var req struct { + Method string `json:"method"` + Path string `json:"path"` + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(plain, &req); err != nil { + writeE2EResp(w, gcm, 400, []byte(`{"error":"requête invalide"}`)) + return + } + // Sécurité : on ne dispatche QUE des chemins d'API internes, jamais /api/e2e/* + // (pas de récursion) ni autre chose (pas d'accès à l'UI/aux assets par ce biais). + if !strings.HasPrefix(req.Path, "/api/") || strings.HasPrefix(req.Path, "/api/e2e") { + writeE2EResp(w, gcm, 403, []byte(`{"error":"chemin interdit"}`)) + return + } + method := req.Method + if method == "" { + method = "GET" + } + // Un chemin/méthode mal formé ferait paniquer httptest.NewRequest : on récupère. + defer func() { + if rec := recover(); rec != nil { + writeE2EResp(w, gcm, 400, []byte(`{"error":"requête malformée"}`)) + } + }() + var bodyReader io.Reader + if len(req.Body) > 0 && string(req.Body) != "null" { + bodyReader = bytes.NewReader(req.Body) + } + ir := httptest.NewRequest(method, req.Path, bodyReader) + ir.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, ir) + respBody := rec.Body.Bytes() + if len(respBody) == 0 { + respBody = []byte("null") + } else if !json.Valid(respBody) { + // Réponse non-JSON (ex : http.Error en texte) : on l'enveloppe en chaîne. + respBody, _ = json.Marshal(string(respBody)) + } + writeE2EResp(w, gcm, rec.Code, respBody) +} + +// writeE2EResp chiffre {status, body} avec la clé du chat et l'écrit en base64 +// (nonce||ct). Le relais ne voit que de l'opaque. +func writeE2EResp(w http.ResponseWriter, gcm cipher.AEAD, status int, body []byte) { + out, _ := json.Marshal(map[string]any{"status": status, "body": json.RawMessage(body)}) + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + http.Error(w, "e2e: nonce", 500) + return + } + sealed := append(nonce, gcm.Seal(nil, nonce, out, nil)...) + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(sealed))) +} + // e2eSeal (côté agent : utilisé uniquement pour les tests) scelle un secret vers // une clé publique X25519. La version navigateur est en WASM (wasmclient). func e2eSeal(agentPub, secret []byte) ([]byte, error) { diff --git a/link.go b/link.go index c485ac5..f060be5 100644 --- a/link.go +++ b/link.go @@ -160,14 +160,24 @@ func newLinkHandler() http.Handler { lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway) } - // Boîte noire : tout ce qui est servi ici transite par le relais. On REFUSE - // donc tout chemin qui ferait passer des prompts/réponses EN CLAIR. Seul le - // chat chiffré de bout en bout (/api/e2e/chat) est autorisé à porter du contenu. + // Boîte noire « zéro exception » : TOUTE l'API (chat ET contrôle) ne transite + // que chiffrée de bout en bout. Le relais ne voit jamais de clair. + // - /api/e2e/chat : chat chiffré (streaming SSE chiffré). + // - /api/e2e/req : proxy de contrôle chiffré (presets, VRAM, skills, service…). + // Tout autre /api/* en clair est REFUSÉ via le tunnel. oaiAllowed := os.Getenv("JEAN_LINK_ALLOW_OAI") == "1" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path - if p == "/api/chat" { - http.Error(w, "chat en clair refusé via le relais : utilise le chat chiffré (boîte noire)", http.StatusForbidden) + if strings.HasPrefix(p, "/api/e2e/") { + if p == "/api/e2e/req" { + handleE2EReq(w, r, web) // dispatche dans le handler local authentifié + return + } + web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux + return + } + if strings.HasPrefix(p, "/api/") { + http.Error(w, "via le relais : seuls les endpoints chiffrés /api/e2e/* sont autorisés (boîte noire)", http.StatusForbidden) return } if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {