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) + } +}