From 9ccdc021cb84d7b88a6004cb1242953ecf1a7771 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:27:43 +0200 Subject: [PATCH] Chiffrement : la discussion active et le mode Code ne deviennent plus illisibles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activer le chiffrement de la mémoire chiffrait TOUTES les valeurs du bucket des discussions, y compris des clés que le code lit et écrit en clair (getStr/putStr) : le pointeur de discussion active, le mode Chat|Code de chaque discussion, la puce « passer en mode Code ». Relues comme du charabia, la discussion active devenait introuvable et le mode Code disparaissait. Les critères, lus en clair eux aussi, s'effaçaient. - Ces clés-pointeurs restent en clair (plainStoreKey) : ni rechiffrées, ni comptées comme « chiffrement incomplet ». - Celles qu'une base existante a déjà chiffrées sont remises en clair dès le déverrouillage, avant le rechargement de la discussion active. - Les critères passent par putStoreBytes/getStoreBytes : chiffrés comme le reste du fil, et lisibles. Co-Authored-By: Claude Opus 5.5 --- internal/loki/code_criteria.go | 4 +-- internal/loki/mem_migrate_test.go | 42 ++++++++++++++++++++++++++++++ internal/loki/mem_store.go | 43 +++++++++++++++++++++++++++++-- internal/loki/web_api.go | 5 +++- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/internal/loki/code_criteria.go b/internal/loki/code_criteria.go index c6e694e..62ea6ef 100644 --- a/internal/loki/code_criteria.go +++ b/internal/loki/code_criteria.go @@ -30,7 +30,7 @@ func critKey(convID string) string { return "crit:" + convID } func critList(convID string) []Criterion { var list []Criterion - if b := getBytes(bkChat, critKey(convID)); len(b) > 0 { + if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 { _ = json.Unmarshal(b, &list) } return list @@ -45,7 +45,7 @@ func critSave(convID string, list []Criterion) { if err != nil { return } - _ = putBytes(bkChat, critKey(convID), b) + _ = putStoreBytes(bkChat, critKey(convID), b) } func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) } diff --git a/internal/loki/mem_migrate_test.go b/internal/loki/mem_migrate_test.go index 2316836..e56e89d 100644 --- a/internal/loki/mem_migrate_test.go +++ b/internal/loki/mem_migrate_test.go @@ -219,3 +219,45 @@ func TestSnapshotRestore(t *testing.T) { } } } + +// Activer le chiffrement ne doit pas rendre illisibles les clés-pointeurs du +// bucket des discussions (discussion active, mode Code) ni les critères. Une +// version précédente les chiffrait : la discussion active devenait +// introuvable. Celles déjà chiffrées sont réparées au déverrouillage. +func TestChiffrementGardeLesPointeursLisibles(t *testing.T) { + testHome(t) + clearMemDEK() + id := convEnsureActive() + if err := setConvMode(id, "code"); err != nil { + t.Fatal(err) + } + toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false) + if _, err := EnableMemEncryption("motdepasse-fort"); err != nil { + t.Fatalf("EnableMemEncryption: %v", err) + } + if got := getStr(bkChat, ckActive); got != id { + t.Fatalf("discussion active illisible après chiffrement : %q", got) + } + if !convCodeMode() { + t.Fatal("mode Code perdu après chiffrement") + } + if l := critList(id); len(l) != 1 { + t.Fatalf("critères perdus après chiffrement : %+v", l) + } + if !looksEncrypted(getBytes(bkChat, critKey(id))) { + t.Fatal("critères en clair alors que les discussions sont chiffrées") + } + if !bucketFullyEncrypted(bkChat) { + t.Fatal("les clés-pointeurs en clair font croire à un chiffrement incomplet") + } + // Base d'une version précédente : le pointeur a été chiffré. + enc, err := encodeMemContent([]byte(id)) + if err != nil { + t.Fatal(err) + } + _ = putBytes(bkChat, ckActive, enc) + healPlainStoreKeys() + if got := getStr(bkChat, ckActive); got != id { + t.Fatalf("pointeur chiffré non réparé : %q", got) + } +} diff --git a/internal/loki/mem_store.go b/internal/loki/mem_store.go index 8c1c4c4..22f1448 100644 --- a/internal/loki/mem_store.go +++ b/internal/loki/mem_store.go @@ -12,6 +12,7 @@ package loki import ( "encoding/json" "errors" + "strings" ) // errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM. @@ -90,12 +91,46 @@ func getStoreJSON(bucket, key string, dst any) bool { return json.Unmarshal(b, dst) == nil } +// plainStoreKey : clés d'un bucket chiffré qui restent EN CLAIR, parce que le +// code les lit et les écrit avec getStr/putStr — des pointeurs et drapeaux, pas +// des données : la discussion active (son id), le mode Chat|Code d'une +// discussion, la puce « passer en mode Code » déjà montrée. Chiffrées par +// reencryptBucket, elles revenaient à la lecture comme du charabia : la +// discussion active devenait introuvable, le mode Code et ses critères +// disparaissaient dès l'activation du chiffrement. +func plainStoreKey(bucket, key string) bool { + if bucket != bkChat { + return false + } + return key == ckActive || strings.HasPrefix(key, "mode:") || strings.HasPrefix(key, "hinted:") +} + +// healPlainStoreKeys remet en clair les clés-pointeurs qu'une version +// précédente avait chiffrées (voir plainStoreKey). À appeler dès que la DEK est +// en RAM, AVANT de recharger la discussion active. Sans DEK, ne fait rien. +func healPlainStoreKeys() { + if !memUnlocked() { + return + } + for _, bucket := range encryptedBuckets { + for k, v := range allKV(bucket) { + raw := []byte(v) + if !plainStoreKey(bucket, k) || !looksEncrypted(raw) { + continue + } + if plain, err := decodeMemContent(raw); err == nil { + _ = putBytes(bucket, k, plain) + } + } + } +} + // reencryptBucket (re)chiffre toutes les valeurs d'un bucket encore en clair, // avec vérification par relecture. Sûr à rejouer. Exige la DEK en RAM. func reencryptBucket(bucket string) error { for k, v := range allKV(bucket) { raw := []byte(v) - if looksEncrypted(raw) { + if looksEncrypted(raw) || plainStoreKey(bucket, k) { continue } if err := putStoreBytes(bucket, k, raw); err != nil { @@ -113,7 +148,10 @@ func reencryptBucket(bucket string) error { // portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement // est réellement complet. func bucketFullyEncrypted(bucket string) bool { - for _, v := range allKV(bucket) { + for k, v := range allKV(bucket) { + if plainStoreKey(bucket, k) { + continue + } if v != "" && !looksEncrypted([]byte(v)) { return false } @@ -136,6 +174,7 @@ var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes} // reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK. func reencryptChatStores() error { + healPlainStoreKeys() for _, b := range encryptedBuckets { if err := reencryptBucket(b); err != nil { return err diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 7830202..84bc33f 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -757,7 +757,10 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) { return } setMemDEK(dek) - resumeMemMigration() // si une migration attendait le déverrouillage + resumeMemMigration() // si une migration attendait le déverrouillage + // AVANT le rechargement : la discussion active est désignée par une clé + // qu'une version précédente chiffrait par erreur (voir plainStoreKey). + healPlainStoreKeys() reloadEncryptedStores() // recharge la discussion chiffrée en RAM // Migration douce : chiffre ce qui serait resté en clair (discussions, // blocs archivés, trackers). Idempotent — n'encode que ce qui ne l'est pas.