Boîte noire « zéro exception » : proxy de contrôle chiffré E2E

- 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.
This commit is contained in:
nathaninline committed 2026-06-24 12:53:31 +02:00
1 parent 27606befe2
commit 0e3951e401
2 files changed
+108 -5

No files matched your search

+93
View File
@@ -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) {
+15 -5
View File
@@ -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") {