Files
Loki/internal/loki/mem_store.go
T
MichaelandClaude Opus 5.5 9ccdc021cb Chiffrement : la discussion active et le mode Code ne deviennent plus illisibles
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 <noreply@anthropic.com>
2026-10-02 23:27:43 +02:00

214 lines
7.0 KiB
Go

package loki
// mem_store.go — chiffrement transparent de VALEURS de la base (bbolt), pour
// étendre le chiffrement au-delà des pages mémoire : conversations et archives
// contiennent aussi des infos sensibles.
//
// Même enveloppe que les pages (encPage/decPage + magic) : une valeur est
// chiffrée quand le chiffrement est actif ET la mémoire déverrouillée, en clair
// sinon. La détection se fait sur le magic, donc clair et chiffré cohabitent
// (utile pendant une migration ou un état verrouillé).
import (
"encoding/json"
"errors"
"strings"
)
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
var errStoreLocked = errors.New("valeur chiffrée, mémoire verrouillée")
// putStoreBytes écrit une valeur, chiffrée si le chiffrement est actif ET
// déverrouillé. Si actif mais verrouillé, renvoie errStoreLocked SANS écrire
// (on ne remplace jamais une valeur chiffrée par du clair).
func putStoreBytes(bucket, key string, plain []byte) error {
out, err := encodeMemContent(plain)
if err != nil {
return errStoreLocked
}
return putBytes(bucket, key, out)
}
// getStoreBytes lit une valeur et la déchiffre au besoin. Renvoie (nil, false)
// si absente, ou si chiffrée alors que la mémoire est verrouillée.
func getStoreBytes(bucket, key string) ([]byte, bool) {
raw := getBytes(bucket, key)
if len(raw) == 0 {
return nil, false
}
dec, err := decodeMemContent(raw)
if err != nil {
return nil, false
}
return dec, true
}
// storeBytes : getStoreBytes réduit à sa valeur, pour les appelants qui
// traitent déjà « absent » et « illisible » de la même façon (un fil vide).
func storeBytes(bucket, key string) []byte {
b, _ := getStoreBytes(bucket, key)
return b
}
// getStoreBytesErr fait la même lecture que getStoreBytes mais SANS avaler
// l'erreur d'accès (getBytes, lui, la jette — c'est le piège documenté sur
// getBytesErr : « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas dire
// la même chose). Une valeur absente reste (nil, nil) — err n'est non-nil QUE
// si la base elle-même n'a pas pu être ouverte/lue (verrou bbolt disputé, disque
// indisponible…), pas pour une clé qui n'existe simplement pas encore.
func getStoreBytesErr(bucket, key string) ([]byte, error) {
raw, err := getBytesErr(bucket, key)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, nil
}
dec, err := decodeMemContent(raw)
if err != nil {
// Chiffré mais verrouillé (ou payload corrompu) : légitime, pas un échec
// d'ACCÈS — même repli que getStoreBytes.
return nil, nil
}
return dec, nil
}
// putStoreJSON sérialise puis écrit (chiffré si actif+déverrouillé).
func putStoreJSON(bucket, key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return putStoreBytes(bucket, key, b)
}
// getStoreJSON lit puis décode (déchiffre au besoin). false si absent/verrouillé.
func getStoreJSON(bucket, key string, dst any) bool {
b, ok := getStoreBytes(bucket, key)
if !ok {
return false
}
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) || plainStoreKey(bucket, k) {
continue
}
if err := putStoreBytes(bucket, k, raw); err != nil {
return err
}
back, ok := getStoreBytes(bucket, k)
if !ok || string(back) != v {
return errors.New("vérification post-chiffrement échouée pour " + bucket + "/" + k)
}
}
return nil
}
// bucketFullyEncrypted indique qu'AUCUNE valeur du bucket n'est en clair (toutes
// portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement
// est réellement complet.
func bucketFullyEncrypted(bucket string) bool {
for k, v := range allKV(bucket) {
if plainStoreKey(bucket, k) {
continue
}
if v != "" && !looksEncrypted([]byte(v)) {
return false
}
}
return true
}
// encryptedBuckets : les buckets dont les valeurs sont chiffrées quand le
// 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), 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, 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
}
}
return nil
}
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
func decryptChatStores() error {
for _, b := range encryptedBuckets {
if err := decryptBucket(b); err != nil {
return err
}
}
return nil
}
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
// la DEK en RAM. Sûr à rejouer.
func decryptBucket(bucket string) error {
for k, v := range allKV(bucket) {
raw := []byte(v)
if !looksEncrypted(raw) {
continue
}
plain, err := decodeMemContent(raw)
if err != nil {
return err
}
if err := putBytes(bucket, k, plain); err != nil {
return err
}
}
return nil
}