mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Index inversé dans le bucket chatsearch (chat_search.go) : une fiche légère par discussion (« d| » : version, date d'indexation, aperçu), sa liste de mots (« t| ») et, par mot, la liste des discussions (« p| »). Mots repliés sans accents, correspondance par début de mot, titre compté double, comme chez l'amont. Chiffré quand la mémoire l'est (encryptedBuckets) ; verrouillée, la recherche le dit (state=locked). Adapté au modèle de Loki : toutes les discussions vivent dans bkChat/conv:<id>. L'index suit chaque écriture de l'écrivain différé (chat_persist.go, avant que le lot soit marqué traité : une suppression passe toujours après) et ne touche que les mots ajoutés ou retirés. Il suit aussi les écritures de la façade Rust, qui passent par /internal/v1/store : le Go reste le seul à tenir l'index, la route /api/conversations/search est servie ici et relayée par la façade comme toute route qu'elle ne connaît pas (test de garde des deux côtés). Pas de second écrivain, donc pas de test de parité à tenir. Rattrapage en fond au démarrage, au déverrouillage et quand une recherche trouve l'index incomplet : discussions absentes ou plus anciennes que leur dernière écriture. La discussion ouverte est cherchée dans son journal en RAM quand ce process la tient. L'extrait est pris autour du mot trouvé, relu dans le corps des seuls résultats de la page. Tests repris d'AJEAN (chat_search_test.go), adaptés, plus les écritures de la façade, l'extrait en contexte, la mise à jour incrémentale et l'index périmé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
8.2 KiB
Go
238 lines
8.2 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), les résultats complets des
|
|
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil) et l'index
|
|
// plein-texte des discussions (bkChatSearch — leurs mots, chat_search.go).
|
|
//
|
|
// 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, bkChatSearch}
|
|
|
|
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
|
|
func reencryptChatStores() error {
|
|
// Écritures différées (chat_persist.go) d'abord : écrites pendant le
|
|
// passage, elles pourraient être écrasées par la relecture d'avant.
|
|
persistQ.flush()
|
|
healPlainStoreKeys()
|
|
for _, b := range encryptedBuckets {
|
|
if err := reencryptBucket(b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé
|
|
// actuelle (écrite avec une autre clé, ou abîmée). Gardée telle quelle.
|
|
type unreadableValue struct {
|
|
Bucket string `json:"bucket"`
|
|
Key string `json:"key"`
|
|
Blob []byte `json:"blob"` // base64 en JSON
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// decryptChatStoresSkipping remet en clair les buckets de conversation (exige
|
|
// la DEK) : déchiffre tout ce qui peut l'être et RENVOIE ce qui
|
|
// ne le peut pas, au lieu de s'arrêter au premier échec : une seule valeur
|
|
// illisible bloquait toute la désactivation du chiffrement, sans dire laquelle
|
|
// (amont, 2026-10-03).
|
|
func decryptChatStoresSkipping() ([]unreadableValue, error) {
|
|
persistQ.flush() // même raison que reencryptChatStores
|
|
var bad []unreadableValue
|
|
for _, b := range encryptedBuckets {
|
|
u, err := decryptBucket(b)
|
|
if err != nil {
|
|
return bad, err
|
|
}
|
|
bad = append(bad, u...)
|
|
}
|
|
return bad, nil
|
|
}
|
|
|
|
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
|
// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle
|
|
// quelle et renvoyée ; seule une erreur d'écriture est fatale.
|
|
func decryptBucket(bucket string) ([]unreadableValue, error) {
|
|
var bad []unreadableValue
|
|
for k, v := range allKV(bucket) {
|
|
raw := []byte(v)
|
|
if !looksEncrypted(raw) {
|
|
continue
|
|
}
|
|
plain, err := decodeMemContent(raw)
|
|
if err != nil {
|
|
bad = append(bad, unreadableValue{Bucket: bucket, Key: k, Blob: raw, Error: err.Error()})
|
|
continue
|
|
}
|
|
if err := putBytes(bucket, k, plain); err != nil {
|
|
return bad, err
|
|
}
|
|
}
|
|
return bad, nil
|
|
}
|