From 58c2da13022d4a67d0ed28f79157f4e2df6bd1b0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:10:56 +0200 Subject: [PATCH] =?UTF-8?q?Chiffrement=20:=20les=20r=C3=A9sultats=20d'outi?= =?UTF-8?q?ls=20et=20les=20images=20du=20fil=20suivent=20la=20m=C3=A9moire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux reprises d'AJEAN arrivées en parallèle du chiffrement écrivaient en clair à côté de discussions chiffrées. - Résultats complets des outils (« voir plus ») : bucket toolres ajouté à encryptedBuckets, écrits et relus par putStoreBytes / getStoreBytesErr. Mémoire verrouillée : rien n'est écrit, le flux porte le résultat entier. Le nettoyage des orphelins ne tourne plus quand la mémoire est verrouillée : l'index des discussions revenait vide et TOUT passait pour orphelin. - Images du fil (chatimg/) : même enveloppe que les pages mémoire ; verrouillée, l'image reste en base64 dans le message. Elles n'étaient jamais effacées : comme un fil rechargé perd ses images (stripImageParts), celles de plus de 24 h partent au démarrage et à chaque bascule de discussion. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_conversation.go | 2 ++ internal/loki/chat_images.go | 40 ++++++++++++++++++++++-- internal/loki/chat_images_test.go | 49 ++++++++++++++++++++++++++++++ internal/loki/mem_store.go | 5 +-- internal/loki/tool_results.go | 14 +++++++-- internal/loki/tool_results_test.go | 30 ++++++++++++++++++ 6 files changed, 133 insertions(+), 7 deletions(-) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index e58fc3a..90b5ee5 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -153,6 +153,7 @@ func LoadConversation() { // Guérit les conversations d'avant le passage des captures en éphémère : un // base64 d'image persisté était rejoué à chaque tour et dépassait le contexte. conv.Messages = stripImageParts(conv.Messages) + go pruneChatImages() // Une génération n'a pas pu survivre à l'arrêt du process : on repart propre. conv.Generating = false conv.cancel = nil @@ -170,6 +171,7 @@ func (c *Conversation) loadFrom(b []byte) { _ = json.Unmarshal(b, c) c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement } + go pruneChatImages() // plus rien ne référence les images de l'ancien fil c.Generating = false c.cancel = nil c.epoch++ diff --git a/internal/loki/chat_images.go b/internal/loki/chat_images.go index 3469cfc..2c30f28 100644 --- a/internal/loki/chat_images.go +++ b/internal/loki/chat_images.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "time" ) // chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE. @@ -43,8 +44,14 @@ func storeChatImage(b []byte, mime string) (string, error) { } p := filepath.Join(dir, name) if _, err := os.Stat(p); err != nil { + // Chiffrée comme le fil quand la mémoire l'est (même enveloppe que les + // pages). Verrouillée : refus, l'image reste en base64 dans le message. + enc, err := encodeMemContent(b) + if err != nil { + return "", err + } tmp := p + ".tmp" - if err := os.WriteFile(tmp, b, 0o600); err != nil { + if err := os.WriteFile(tmp, enc, 0o600); err != nil { return "", err } if err := os.Rename(tmp, p); err != nil { @@ -76,7 +83,11 @@ func chatImageDataURL(ref string) (string, bool) { return u, true } imgCacheMu.Unlock() - b, err := os.ReadFile(filepath.Join(chatImgDir(), name)) + raw, err := os.ReadFile(filepath.Join(chatImgDir(), name)) + if err != nil { + return "", false + } + b, err := decodeMemContent(raw) if err != nil { return "", false } @@ -103,6 +114,31 @@ func chatImageDataURL(ref string) (string, bool) { return u, true } +// chatImgKeep : âge minimal d'une image avant qu'on puisse l'effacer. +const chatImgKeep = 24 * time.Hour + +// pruneChatImages efface les images de plus de chatImgKeep. Appelée au +// démarrage et à chaque bascule de discussion, c'est-à-dire quand le fil +// vivant vient d'être rechargé — et stripImageParts retire les images d'un fil +// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un +// fil resté ouvert longtemps sans bascule. +func pruneChatImages() { + dir := chatImgDir() + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-chatImgKeep) + for _, e := range entries { + if e.IsDir() { + continue + } + if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} + // contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre // forme (fraîche []map, ou relue du JSON []any). func contentParts(content any) []map[string]any { diff --git a/internal/loki/chat_images_test.go b/internal/loki/chat_images_test.go index 2f7a954..cf6f661 100644 --- a/internal/loki/chat_images_test.go +++ b/internal/loki/chat_images_test.go @@ -3,8 +3,11 @@ package loki import ( "encoding/base64" "encoding/json" + "os" + "path/filepath" "strings" "testing" + "time" ) // Une image en base64 part sur disque et revient identique à l'envoi au modèle ; @@ -44,3 +47,49 @@ func TestImageRefsRoundTrip(t *testing.T) { } } } + +// Mémoire chiffrée : l'image rangée est chiffrée sur disque et revient intacte ; +// verrouillée, elle n'est pas écrite (elle reste en base64 dans le message). +func TestImageRefsChiffrees(t *testing.T) { + testHome(t) + clearMemDEK() + if _, err := EnableMemEncryption("motdepasse-fort"); err != nil { + t.Fatalf("EnableMemEncryption: %v", err) + } + raw := []byte(strings.Repeat("\xff\xd8JPEG-fake", 3000)) + ref, err := storeChatImage(raw, "image/jpeg") + if err != nil { + t.Fatalf("storeChatImage: %v", err) + } + disk, _ := os.ReadFile(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))) + if !looksEncrypted(disk) { + t.Fatal("image en clair sur disque alors que la mémoire est chiffrée") + } + u, ok := chatImageDataURL(ref) + if !ok || u != "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw) { + t.Fatal("image chiffrée non restituée à l'identique") + } + clearMemDEK() + if _, err := storeChatImage([]byte("autre image"), "image/png"); err == nil { + t.Fatal("mémoire verrouillée : l'image n'aurait pas dû être écrite") + } +} + +// Les images de plus de chatImgKeep sont effacées, les récentes restent. +func TestPruneChatImages(t *testing.T) { + testHome(t) + vieille, _ := storeChatImage([]byte("vieille"), "image/png") + recente, _ := storeChatImage([]byte("récente"), "image/png") + pv := filepath.Join(chatImgDir(), strings.TrimPrefix(vieille, imgRefScheme)) + old := time.Now().Add(-chatImgKeep - time.Hour) + if err := os.Chtimes(pv, old, old); err != nil { + t.Fatal(err) + } + pruneChatImages() + if _, err := os.Stat(pv); !os.IsNotExist(err) { + t.Fatal("la vieille image aurait dû être effacée") + } + if _, err := os.Stat(filepath.Join(chatImgDir(), strings.TrimPrefix(recente, imgRefScheme))); err != nil { + t.Fatal("la récente image a disparu") + } +} diff --git a/internal/loki/mem_store.go b/internal/loki/mem_store.go index f810a15..8c1c4c4 100644 --- a/internal/loki/mem_store.go +++ b/internal/loki/mem_store.go @@ -125,13 +125,14 @@ func bucketFullyEncrypted(bucket string) bool { // chiffrement est actif. Ici : les discussions (bkChat porte l'index, la // discussion active et le journal de chacune), les blocs archivés au compactage // (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et -// les trackers (bkTracker, 3ᵉ type de mémoire). +// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des +// outils gardés pour « voir plus » (bkToolRes — des extraits du fil). // // Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils // portent des réglages, pas des données personnelles — et surtout le coffre // lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une // boucle : plus moyen de déverrouiller quoi que ce soit. -var encryptedBuckets = []string{bkChat, bkRecall, bkTracker} +var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes} // reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK. func reencryptChatStores() error { diff --git a/internal/loki/tool_results.go b/internal/loki/tool_results.go index 1a409a7..25376eb 100644 --- a/internal/loki/tool_results.go +++ b/internal/loki/tool_results.go @@ -10,7 +10,8 @@ import ( ) // tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ». -// Repris d'AJEAN 0.15.5 / 0.15.7, sans le chiffrement (Loki n'en a pas). +// Repris d'AJEAN 0.15.5 / 0.15.7. Chiffré comme les discussions quand le +// chiffrement mémoire est actif (bkToolRes figure dans encryptedBuckets). // // Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge // au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour @@ -46,7 +47,9 @@ func toolResID(sid string) string { // entier dans le flux). func saveToolResult(result string) string { id := toolResID(getStr(bkChat, ckActive)) - if id == "" || putBytes(bkToolRes, id, []byte(result)) != nil { + // Chiffrement actif mais mémoire verrouillée : putStoreBytes refuse d'écrire + // en clair, et l'appelant envoie alors le résultat entier dans le flux. + if id == "" || putStoreBytes(bkToolRes, id, []byte(result)) != nil { return "" } if toolResWrites.Add(1)%toolResPruneGap == 0 { @@ -60,7 +63,7 @@ func loadToolResult(id string) (string, bool) { if strings.ContainsAny(id, "/\\") { return "", false } - b, err := getBytesErr(bkToolRes, id) + b, err := getStoreBytesErr(bkToolRes, id) if err != nil || b == nil { return "", false } @@ -90,6 +93,11 @@ func deleteToolResultsFor(sid string) { // pruneToolResults supprime les résultats de discussions qui n'existent plus. func pruneToolResults() { + // Mémoire verrouillée : l'index des discussions est illisible et reviendrait + // vide — tout passerait pour orphelin. On attend le déverrouillage. + if memEncActive() && !memUnlocked() { + return + } alive := map[string]bool{getStr(bkChat, ckActive): true} for _, m := range convIndex() { alive[m.ID] = true diff --git a/internal/loki/tool_results_test.go b/internal/loki/tool_results_test.go index d791245..5966bf5 100644 --- a/internal/loki/tool_results_test.go +++ b/internal/loki/tool_results_test.go @@ -40,3 +40,33 @@ func TestTailOutputAnnouncesCut(t *testing.T) { t.Fatalf("mention de coupe absente : %q", out[:60]) } } + +// Mémoire chiffrée : le résultat complet est chiffré en base et relu intact ; +// verrouillée, rien n'est écrit (le flux portera le résultat entier) et le +// nettoyage des orphelins ne tourne pas (l'index illisible ferait tout effacer). +func TestToolResultsChiffres(t *testing.T) { + testHome(t) + clearMemDEK() + if _, err := EnableMemEncryption("motdepasse-fort"); err != nil { + t.Fatalf("EnableMemEncryption: %v", err) + } + full := strings.Repeat("ligne de sortie\n", 500) + id := saveToolResult(full) + if id == "" { + t.Fatal("résultat non enregistré") + } + if !looksEncrypted(getBytes(bkToolRes, id)) { + t.Fatal("résultat en clair alors que la mémoire est chiffrée") + } + if got, ok := loadToolResult(id); !ok || got != full { + t.Fatal("résultat chiffré non restitué à l'identique") + } + clearMemDEK() + if saveToolResult("autre") != "" { + t.Fatal("mémoire verrouillée : rien n'aurait dû être écrit") + } + pruneToolResults() + if getBytes(bkToolRes, id) == nil { + t.Fatal("le nettoyage a effacé un résultat pendant que la mémoire était verrouillée") + } +}