Files
Loki/internal/ajean/relay_e2e.go
T
nathaninline 78423276e7 Telecharger un fichier depuis ajean.link rendait du JSON
Le proxy chiffre (relay_e2e.go) reemballe TOUTE reponse en JSON avant de
la renvoyer : une reponse non-JSON est enveloppee en chaine. Du binaire
n'y survit pas — les octets non-UTF8 sont massacres — et le navigateur
telechargeait l'enveloppe JSON a la place du fichier. Le probleme est
structurel, pas propre au telechargement : il touchait aussi l'export
Markdown de conversation, qui revenait entre guillemets, echappements
compris.

handleChatFile prend donc deux formes supplementaires : `meta=1` rend une
fiche {name, size, e2e}, et `b64=1&offset=&len=` une tranche en base64.
Le client demande la fiche d'abord — minuscule, elle passe partout — et
en tire le transport a utiliser : binaire direct en local, tranches
base64 derriere le tunnel. Le client ne peut pas le deviner autrement, le
proxy lui rendant des reponses JSON parfaitement ordinaires.

Le decoupage en tranches de 8 Mo evite de tenir le fichier entier en
memoire pour le transporter, comme a l'envoi. Verifie sur 120 Mo : les
deux chemins rendent exactement les memes 125 829 120 octets, et le test
Go compare octet a octet sur des donnees non-UTF8, celles que le
reemballage JSON detruisait.

relay_e2e.go marque desormais les requetes venues du tunnel
(X-Ajean-E2E) : c'est le seul moyen pour un handler de savoir que sa
reponse sera reemballee.
2026-08-10 20:29:57 +02:00

265 lines
8.4 KiB
Go

package ajean
// 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 `ajean 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(AjeanHome(), ".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(AjeanHome(), 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()
emit := func(obj map[string]any) 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 false
}
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
mu.Lock()
defer mu.Unlock()
if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
return false
}
if flusher != nil {
flusher.Flush()
}
return true
}
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) {
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)))
}