From c38b1ac43e89d2f72b66c52eba079377c4b082c6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 15 Oct 2026 08:49:08 +0200 Subject: [PATCH] API interne de chiffrement pour la conversation tenue par Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les discussions sont chiffrées en base quand la mémoire l'est, avec une clé de données qui ne vit qu'en mémoire du Go après déverrouillage. Une conversation tenue par la façade fera sceller et ouvrir ses blobs ici : la clé ne sort jamais. Mémoire verrouillée : 423, jamais d'écriture en clair ni de perte — l'appelant attend le déverrouillage, comme le Go. Co-Authored-By: Claude Opus 5.5 --- internal/loki/web_internal.go | 4 ++ internal/loki/web_internal_crypt.go | 56 ++++++++++++++++++++++++ internal/loki/web_internal_crypt_test.go | 50 +++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 internal/loki/web_internal_crypt.go create mode 100644 internal/loki/web_internal_crypt_test.go diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index a2f231e..3391d6b 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -71,6 +71,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIEnd(w, r) case "/internal/v1/tool/exec": handleInternalToolExec(w, r) + case "/internal/v1/crypt/seal": + handleInternalCrypt(w, r, true) + case "/internal/v1/crypt/open": + handleInternalCrypt(w, r, false) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_crypt.go b/internal/loki/web_internal_crypt.go new file mode 100644 index 0000000..cc87fe1 --- /dev/null +++ b/internal/loki/web_internal_crypt.go @@ -0,0 +1,56 @@ +package loki + +import ( + "errors" + "io" + "net/http" +) + +// API interne de chiffrement (lot 6 de la migration Rust). +// +// Les discussions sont chiffrées en base quand la mémoire l'est +// (memEncoderNow / decodeMemContent). La clé de données (DEK) ne vit qu'en +// mémoire de ce process, après déverrouillage : une conversation tenue par la +// façade Rust fait donc sceller et ouvrir ses blobs ICI, la clé ne sort jamais. +// +// POST /internal/v1/crypt/seal corps clair → 200 corps scellé (ou clair si +// le chiffrement est coupé) +// POST /internal/v1/crypt/open corps brut → 200 corps clair (un contenu +// non chiffré passe tel quel) +// +// Mémoire verrouillée : 423 (Locked) — l'appelant ne doit ni écrire en clair +// ni perdre la donnée, il attend le déverrouillage comme le Go le fait. + +const internalCryptMaxBody = 256 << 20 + +func handleInternalCrypt(w http.ResponseWriter, r *http.Request, seal bool) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + in, err := io.ReadAll(io.LimitReader(r.Body, internalCryptMaxBody+1)) + if err != nil || len(in) > internalCryptMaxBody { + sendJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "corps trop grand ou illisible"}) + return + } + var out []byte + if seal { + var enc func([]byte) ([]byte, error) + if enc, err = memEncoderNow(); err == nil { + out, err = enc(in) + } + } else { + out, err = decodeMemContent(in) + } + if errors.Is(err, errMemLocked) { + sendJSON(w, http.StatusLocked, map[string]any{"error": err.Error()}) + return + } + if err != nil { + sendJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(out) +} diff --git a/internal/loki/web_internal_crypt_test.go b/internal/loki/web_internal_crypt_test.go new file mode 100644 index 0000000..41083b7 --- /dev/null +++ b/internal/loki/web_internal_crypt_test.go @@ -0,0 +1,50 @@ +package loki + +import ( + "bytes" + "testing" +) + +// Sceller puis ouvrir par l'API interne rend le clair ; la clé ne sort pas. +func TestCryptInterneAllerRetour(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + + // Chiffrement coupé : scellé = clair, et un clair s'ouvre tel quel. + w := appelOAIInterne(t, "POST", "/internal/v1/crypt/seal", "bonjour") + if w.Code != 200 || w.Body.String() != "bonjour" { + t.Fatalf("seal sans chiffrement : %d %q", w.Code, w.Body.String()) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/crypt/open", "bonjour"); w.Body.String() != "bonjour" { + t.Fatalf("open d'un clair : %q", w.Body.String()) + } + + // Chiffrement actif, mémoire verrouillée : 423, jamais d'écriture en clair. + if err := putStr(bkConfig, "MEM_ENCRYPTED", "1"); err != nil { + t.Fatal(err) + } + clearMemDEK() + if w := appelOAIInterne(t, "POST", "/internal/v1/crypt/seal", "secret"); w.Code != 423 { + t.Fatalf("seal verrouillé : %d", w.Code) + } + + // Déverrouillée : scellé ≠ clair, ouverture = clair. + dek, _ := randBytes(memDEKLen) + setMemDEK(dek) + t.Cleanup(clearMemDEK) + w = appelOAIInterne(t, "POST", "/internal/v1/crypt/seal", "secret") + sealed := w.Body.Bytes() + if w.Code != 200 || bytes.Contains(sealed, []byte("secret")) || !looksEncrypted(sealed) { + t.Fatalf("seal : %d %q", w.Code, sealed) + } + w = appelOAIInterne(t, "POST", "/internal/v1/crypt/open", string(sealed)) + if w.Code != 200 || w.Body.String() != "secret" { + t.Fatalf("open : %d %q", w.Code, w.Body.String()) + } + + // Reverrouillée : un scellé ne s'ouvre plus. + clearMemDEK() + if w := appelOAIInterne(t, "POST", "/internal/v1/crypt/open", string(sealed)); w.Code != 423 { + t.Fatalf("open verrouillé : %d", w.Code) + } +}