mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 05:58:13 +02:00
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc installe. Trois briques : - module github.com/nathaninline/ajean, cmd/ajean, internal/ajean, package ajean. Purement interne. - AjeanHome() remplace JeanHome(). Les defauts deviennent %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant au premier lancement. La migration est un os.Rename dans le dossier parent de l'ancien chemin : intra-volume, donc atomique et instantane meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue (handle ouvert sous Windows, droits, volume en lecture seule) on CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais de suppression, le pire cas est "rien n'a bouge". $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que /etc/default/ajean et /etc/default/jean : un chemin impose a la main est un ordre, on ne le migre pas. - l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>, dans cet ordre. Indispensable dans les deux sens : les binaires deja installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir s'installer depuis une release anterieure au renommage. Couvert par des tests, dont le repli quand un handle ouvert bloque le rename sous Windows.
291 lines
8.8 KiB
Go
291 lines
8.8 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 `jean 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")
|
|
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) {
|
|
pub, err := ecdh.X25519().NewPublicKey(agentPub)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
shared, err := eph.ECDH(pub)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key := sealKey(shared, eph.PublicKey().Bytes(), agentPub)
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nonce := make([]byte, 12)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
ct := gcm.Seal(nil, nonce, secret, nil)
|
|
out := append([]byte{}, eph.PublicKey().Bytes()...)
|
|
out = append(out, nonce...)
|
|
out = append(out, ct...)
|
|
return out, nil
|
|
}
|