mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Pendant qu'un modèle tapait un write de 120 Ko, chaque morceau faisait relire et redécoder tout le JSON des arguments, puis republiait le corps ENTIER à chaque ligne : 250 Mo de flux SSE, autant de tas vivant dans le journal, et près de 8 s de CPU volées au décodage quand des experts tournent sur le processeur. Même travers pour la détection des appels d'outils écrits en texte, qui repassait toutes les regex sur toute la réponse à chaque jeton. Rien de ce que voit le modèle ne change : les arguments exécutés restent entiers, la passe complète de fin de tour aussi. - argPreview : lecture incrémentale de l'argument affiché et du corps, même règle que previewArgDone (barre oblique coupée gardée en attente) ; arguments accumulés dans un strings.Builder, cur.Function.Arguments recalé sur lui à chaque morceau. - Événement de frappe : seules les 40 dernières lignes (4 Kio au plus) du corps, avec body_lines (vrai « +N ») et body_tail ; l'UI affiche « … » et garde le repli sur le décompte local. - textualToolCallFrom : ne relit que la fin, depuis un vrai début de ligne avant la fin du balayage précédent, en reculant sur les suites que les motifs embarqués peuvent traverser. Surcharge retry_patterns : balayage complet, comme avant. - Direct SSE : les événements trouvés à un réveil partent en une écriture (lots de 128 Kio / 256 événements au plus), un sceau par événement en E2E. - Tests : fuzz d'argPreview contre previewArgDone, équivalence fenêtre / balayage complet au morceau près, corps borné de bout en bout, ordre des lots ; bancs d'essai. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
8.4 KiB
Go
258 lines
8.4 KiB
Go
package loki
|
|
|
|
// relay_e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de
|
|
// sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ».
|
|
//
|
|
// Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais
|
|
// transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée
|
|
// au `loki link` — l'utilisateur la confirme dans le portail, ce qui défait tout
|
|
// MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey
|
|
// OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas
|
|
// l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R.
|
|
//
|
|
// Aucune dépendance externe : crypto/ecdh (X25519) + AES-GCM de la stdlib.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
func e2eKeyPath() string { return filepath.Join(LokiHome(), ".e2e_key") }
|
|
|
|
var (
|
|
e2eOnce sync.Once
|
|
e2eKey *ecdh.PrivateKey
|
|
e2eErr error
|
|
)
|
|
|
|
// e2ePrivateKey charge (ou crée puis persiste) la clé privée X25519 de l'agent.
|
|
func e2ePrivateKey() (*ecdh.PrivateKey, error) {
|
|
e2eOnce.Do(func() {
|
|
if b, err := os.ReadFile(e2eKeyPath()); err == nil {
|
|
raw, derr := hex.DecodeString(strings.TrimSpace(string(b)))
|
|
if derr == nil {
|
|
e2eKey, e2eErr = ecdh.X25519().NewPrivateKey(raw)
|
|
return
|
|
}
|
|
}
|
|
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
e2eErr = err
|
|
return
|
|
}
|
|
_ = os.MkdirAll(LokiHome(), 0o755)
|
|
if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
|
|
e2eErr = err
|
|
return
|
|
}
|
|
e2eKey = k
|
|
})
|
|
return e2eKey, e2eErr
|
|
}
|
|
|
|
// e2ePubHex retourne la clé publique X25519 de l'agent (hex), pour publication au relais.
|
|
func e2ePubHex() string {
|
|
k, err := e2ePrivateKey()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(k.PublicKey().Bytes())
|
|
}
|
|
|
|
// e2eFingerprint retourne une empreinte lisible de la clé publique (à comparer
|
|
// dans le portail). Format : 8 groupes hex de 4 = SHA-256(pub) tronqué.
|
|
func e2eFingerprint() string {
|
|
k, err := e2ePrivateKey()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
h := sha256.Sum256(k.PublicKey().Bytes())
|
|
s := hex.EncodeToString(h[:8]) // 16 hex
|
|
var parts []string
|
|
for i := 0; i < len(s); i += 4 {
|
|
parts = append(parts, strings.ToUpper(s[i:i+4]))
|
|
}
|
|
return strings.Join(parts, "-")
|
|
}
|
|
|
|
// e2eOpenSeal ouvre une boîte scellée (ephPub32 || nonce12 || ct) chiffrée vers
|
|
// la clé publique de l'agent, et retourne le secret en clair (la racine R).
|
|
func e2eOpenSeal(blob []byte) ([]byte, error) {
|
|
if len(blob) < 32+12+16 {
|
|
return nil, fmt.Errorf("sceau trop court")
|
|
}
|
|
priv, err := e2ePrivateKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ephPubBytes := blob[:32]
|
|
nonce := blob[32:44]
|
|
ct := blob[44:]
|
|
ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
shared, err := priv.ECDH(ephPub)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key := sealKey(shared, ephPubBytes, priv.PublicKey().Bytes())
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gcm.Open(nil, nonce, ct, nil)
|
|
}
|
|
|
|
// sealKey dérive la clé AES de la boîte scellée (liée aux deux clés publiques).
|
|
func sealKey(shared, ephPub, agentPub []byte) []byte {
|
|
h := sha256.New()
|
|
h.Write(shared)
|
|
h.Write(ephPub)
|
|
h.Write(agentPub)
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
|
blk, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(blk)
|
|
}
|
|
|
|
// handleE2EChat : chat chiffré de bout en bout, AUTHENTIFIÉ. L'enveloppe est
|
|
// déchiffrée avec la clé du canal authentifié (liée à l'identité appairée de
|
|
// l'utilisateur) — un relais ne peut ni la lire ni la forger. Chaque événement SSE
|
|
// est chiffré. Le relais ne voit que de l'opaque.
|
|
func handleE2EChat(w http.ResponseWriter, r *http.Request) {
|
|
plain, key, err := e2eAuthOpenReq(r)
|
|
if err != nil {
|
|
http.Error(w, "e2e: "+err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
http.Error(w, "e2e: clé", 500)
|
|
return
|
|
}
|
|
var body chatReq
|
|
if err := json.Unmarshal(plain, &body); err != nil {
|
|
http.Error(w, "e2e: requête", 400)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
// no-transform : interdit à Cloudflare (proxy orange sur ajean.link) de
|
|
// bufferiser/compresser le flux — sinon les événements arrivent en retard.
|
|
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
flusher, _ := w.(http.Flusher)
|
|
mu, stop := sseHeartbeat(w, flusher)
|
|
defer stop()
|
|
// Un sceau par événement, même quand le direct les écrit par lots.
|
|
frame := func(obj map[string]any) ([]byte, bool) {
|
|
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
|
nonce := make([]byte, 12)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, false
|
|
}
|
|
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
|
|
return []byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n"), true
|
|
}
|
|
runChatStream(r.Context(), body, &sseStream{w: w, flusher: flusher, mu: mu, frame: frame})
|
|
}
|
|
|
|
// 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) {
|
|
plain, key, err := e2eAuthOpenReq(r)
|
|
if err != nil {
|
|
http.Error(w, "e2e: "+err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
http.Error(w, "e2e: clé", 500)
|
|
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")
|
|
// Marque la requête comme arrivée PAR LE TUNNEL. Tout ce qui ressort d'ici est
|
|
// réemballé en JSON (voir plus bas) : un handler qui renvoie du binaire — le
|
|
// téléchargement d'un fichier — doit le savoir pour proposer une forme qui
|
|
// traverse, sans quoi le client reçoit l'enveloppe JSON à la place du fichier.
|
|
ir.Header.Set(e2eInnerHeader, "1")
|
|
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)))
|
|
}
|