mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
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:
1 parent
27606befe2
commit
0e3951e401
2 files changed
+108
-5
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user