Files
Loki/internal/loki/mem_migrate.go
T
MichaelandClaude Opus 5.5 127013b32b Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur
- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur
  illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la
  reprise au démarrage déchiffre aussi les conversations au lieu de retirer la
  clé après les seules pages. Propre à Loki : les images de conversation
  (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient
  chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés.
- Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle
  systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du
  conteneur ne le prend plus pour un plantage.
- Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s
  en pleine génération).
- Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler.
- Paramètres → Configuration : commande exacte du moteur copiable (clé API
  masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu
  dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:28:42 +02:00

447 lines
16 KiB
Go

package loki
// mem_migrate.go — bascule chiffrer / déchiffrer la mémoire, sans jamais risquer
// de la perdre. Règles :
// - auto-test crypto AVANT de toucher quoi que ce soit ;
// - snapshot complet AVANT toute bascule ;
// - écriture atomique + relecture vérifiée de CHAQUE page ;
// - journal de migration pour reprendre proprement après un crash ;
// - une page à moitié migrée reste lisible : le contenu est auto-descriptif
// (magic), donc pages chiffrées et pages claires coexistent sans danger.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func migrationJournalPath() string { return filepath.Join(memoryDir(), ".migration") }
type migrationJournal struct {
Op string `json:"op"` // "encrypt" | "decrypt"
StartedAt time.Time `json:"started_at"`
}
func writeMigrationJournal(op string) error {
b, _ := json.Marshal(migrationJournal{Op: op, StartedAt: time.Now().UTC()})
return memWriteFileAtomic(migrationJournalPath(), b, 0o600)
}
func clearMigrationJournal() { os.Remove(migrationJournalPath()) }
func readMigrationJournal() (*migrationJournal, bool) {
b, err := os.ReadFile(migrationJournalPath())
if err != nil {
return nil, false
}
var j migrationJournal
if json.Unmarshal(b, &j) != nil {
return nil, false
}
return &j, true
}
// mdPages liste les noms de fichiers .md du dossier mémoire (les pages) du projet
// ACTIF. Utilisé par les affichages ; le chiffrement, lui, passe par
// allMemPageFiles pour couvrir TOUS les projets.
func mdPages() []string {
entries, err := os.ReadDir(memoryDir())
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
out = append(out, e.Name())
}
}
return out
}
// allMemPageFiles renvoie les chemins ABSOLUS de toutes les pages .md de TOUS les
// projets (memory/<slug>/*.md). Sert au chiffrement/déchiffrement global : la DEK
// est unique, mais les pages sont réparties par projet.
func allMemPageFiles() []string {
var out []string
roots, err := os.ReadDir(projectsRoot())
if err != nil {
return nil
}
for _, d := range roots {
if !d.IsDir() {
continue
}
dir := filepath.Join(projectsRoot(), d.Name())
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
out = append(out, filepath.Join(dir, e.Name()))
}
}
}
return out
}
// EnableMemEncryption chiffre la mémoire. Génère la DEK, la wrappe sous le mot de
// passe ET sous une clé de récupération (renvoyée : à montrer UNE fois à
// l'utilisateur), puis chiffre chaque page en vérifiant. Idempotent-safe : si
// déjà activé, renvoie une erreur claire.
func EnableMemEncryption(password string) (recoveryKey string, err error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("mot de passe vide")
}
if memEncActive() {
return "", fmt.Errorf("le chiffrement est déjà activé")
}
// GARDE-FOU ANTI-PERTE : refuser de créer un nouveau coffre si un coffre existe
// DÉJÀ sur le disque, même quand le drapeau MEM_ENCRYPTED a disparu. Sinon un
// « ré-activer » (drapeau perdu, ex. effacé par un changement de preset) forgeait
// une NOUVELLE DEK et re-chiffrait par-dessus : les pages déjà chiffrées sous
// l'ancienne DEK devenaient définitivement illisibles. Ici on impose de
// DÉVERROUILLER l'existant (unlock) au lieu de re-chiffrer. C'est ce bug qui a
// orphelin des pages le 2026-08-25.
if vaultExists() {
return "", fmt.Errorf("un coffre de chiffrement existe déjà : déverrouille la mémoire (unlock) au lieu de la re-chiffrer")
}
if err := memCryptoSelfTest(); err != nil {
return "", fmt.Errorf("auto-test crypto échoué, chiffrement refusé : %w", err)
}
if _, err := snapshotMemory("avant-chiffrement"); err != nil {
return "", fmt.Errorf("snapshot de sécurité impossible, chiffrement annulé : %w", err)
}
v, dek, err := newVault()
if err != nil {
return "", err
}
if err := v.addSecretWrap(dek, wrapPassword, "principal", password); err != nil {
return "", err
}
recoveryKey, err = newRecoveryKey()
if err != nil {
return "", err
}
if err := v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(recoveryKey)); err != nil {
return "", err
}
if err := saveVault(v); err != nil {
return "", fmt.Errorf("écriture du keyvault : %w", err)
}
// À partir d'ici la DEK est en RAM et le chiffrement est actif : writeMemFile
// chiffrera. On journalise, on migre chaque page, on lève le journal.
setMemDEK(dek)
if err := SetConfigKey("MEM_ENCRYPTED", "1"); err != nil {
return "", err
}
if err := writeMigrationJournal("encrypt"); err != nil {
return "", err
}
if err := reencryptPlaintextPages(); err != nil {
// Journal laissé en place : la reprise au démarrage réessaiera. Les pages
// déjà chiffrées sont valides, les pages claires restent lisibles.
return recoveryKey, fmt.Errorf("chiffrement partiel (sera repris) : %w", err)
}
// Étend le chiffrement aux conversations (fil courant + archives + index).
if err := reencryptChatStores(); err != nil {
return recoveryKey, fmt.Errorf("chiffrement conversations partiel (sera repris) : %w", err)
}
clearMigrationJournal()
scrubPlaintextResidue() // sinon le texte en clair traînerait à côté du chiffré
return recoveryKey, nil
}
// scrubPlaintextResidue efface le texte en clair laissé sur le disque APRÈS un
// chiffrement réussi et vérifié : les .bak clairs (copies de sauvegarde d'une
// écriture) et le snapshot « avant-chiffrement » (copie intégrale en clair).
// Sans ça, chiffrer ne servirait à rien — le clair resterait lisible à côté.
// Sûr : les pages chiffrées ont été relues et validées une par une, le keyvault
// (3 copies + clé de récupération) garantit la récupération.
func scrubPlaintextResidue() {
// .bak clairs de TOUS les projets (memory/<slug>/*.bak).
if roots, err := os.ReadDir(projectsRoot()); err == nil {
for _, d := range roots {
if !d.IsDir() {
continue
}
dir := filepath.Join(projectsRoot(), d.Name())
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".bak") {
continue
}
p := filepath.Join(dir, e.Name())
if b, err := os.ReadFile(p); err == nil && !looksEncrypted(b) {
os.Remove(p) // .bak en clair : à retirer
}
}
}
}
for _, s := range listSnapshots() {
if strings.Contains(s.Reason, "avant-chiffrement") {
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
}
}
}
// reencryptPlaintextPages (re)chiffre toutes les pages encore en clair, DANS TOUS
// LES PROJETS, et vérifie chacune par relecture. Sûr à rejouer.
func reencryptPlaintextPages() error {
dek, err := currentDEK()
if err != nil {
return fmt.Errorf("DEK indisponible : %w", err)
}
for _, p := range allMemPageFiles() {
raw, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("lecture %s : %w", p, err)
}
if looksEncrypted(raw) {
continue // déjà fait
}
enc, err := encPage(dek, raw)
if err != nil {
return fmt.Errorf("chiffrement %s : %w", p, err)
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, enc, 0o600); err != nil {
return fmt.Errorf("écriture chiffrée %s : %w", p, err)
}
// Vérifie que la page se relit et se déchiffre à l'identique.
back, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("relecture %s : %w", p, err)
}
plain, err := decPage(dek, back)
if err != nil || string(plain) != string(raw) {
return fmt.Errorf("vérification post-chiffrement de %s échouée", p)
}
}
return nil
}
// DisableMemEncryption remet la mémoire en clair. Exige que la mémoire soit
// déverrouillée (DEK en RAM). Snapshot d'abord, puis chaque page est déchiffrée,
// écrite en clair et vérifiée ; enfin le keyvault est retiré.
func DisableMemEncryption() error {
if !memEncActive() {
return fmt.Errorf("le chiffrement n'est pas activé")
}
if !memUnlocked() {
return fmt.Errorf("mémoire verrouillée : déverrouille-la avant de déchiffrer")
}
if _, err := snapshotMemory("avant-dechiffrement"); err != nil {
return fmt.Errorf("snapshot de sécurité impossible, déchiffrement annulé : %w", err)
}
if err := writeMigrationJournal("decrypt"); err != nil {
return err
}
// On garde le drapeau LEVÉ et la DEK en RAM pendant toute l'opération : on
// écrit le clair via writeMemPlain (qui ignore le drapeau), on vérifie CHAQUE
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
// levé, donc DisableMemEncryption peut être relancé).
return finishDecryption()
}
// finishDecryption : fichiers (pages, images des conversations), puis
// conversations, puis retrait de la clé, dans cet ordre et seulement si tout ce
// qui précède a réussi. Sûr à rejouer. Partagé avec la reprise au démarrage, qui
// retirait la clé après les seules pages : les conversations restaient
// chiffrées sans plus aucun moyen de les rouvrir (amont cac0cda).
func finishDecryption() error {
bad, err := decryptAllPagesSkipping()
if err != nil {
return err
}
badKV, err := decryptChatStoresSkipping()
if err != nil {
return err
}
bad = append(bad, badKV...)
if len(bad) > 0 {
// Valeurs illisibles avec la clé actuelle : déjà inaccessibles avant ce
// déchiffrement. On les met en quarantaine (toujours chiffrées) avec une
// copie du keyvault, pour pouvoir encore les rouvrir plus tard, puis on
// les retire de la base pour que le reste fonctionne en clair.
if err := quarantineUnreadable(bad); err != nil {
return fmt.Errorf("mise en quarantaine des valeurs illisibles impossible, déchiffrement interrompu (rien n'est perdu, il peut être relancé) : %w", err)
}
for _, u := range bad {
if u.Bucket != "fichier" { // un fichier reste en place, sa copie est en quarantaine
_ = putBytes(u.Bucket, u.Key, nil)
}
}
}
// Tout est en clair et vérifié : on peut baisser le drapeau, retirer le
// keyvault et purger la DEK.
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
return err
}
removeVault()
clearMemDEK()
clearMigrationJournal()
if n := scrubEncryptedResidue(); n > 0 {
fmt.Fprintf(os.Stderr, "[mémoire] %d copie(s) .bak chiffrée(s), illisibles sans la clé, retirée(s)\n", n)
}
return nil
}
// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de
// la mémoire (tous projets) et des images de conversation, en vérifiant chacun.
// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel
// quel et renvoyé au lieu de tout arrêter.
func decryptAllPagesSkipping() ([]unreadableValue, error) {
var bad []unreadableValue
for _, p := range allMemFilesForDecrypt() {
raw, err := os.ReadFile(p)
if err != nil {
return bad, fmt.Errorf("lecture %s : %w", p, err)
}
if !looksEncrypted(raw) {
continue // déjà en clair
}
plain, err := decodeMemContent(raw)
if err != nil {
bad = append(bad, unreadableValue{Bucket: "fichier", Key: p, Blob: raw, Error: err.Error()})
continue // on n'écrase rien
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
return bad, fmt.Errorf("écriture claire %s : %w", p, err)
}
back, err := os.ReadFile(p)
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
return bad, fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
}
}
return bad, nil
}
// allMemFilesForDecrypt : TOUS les fichiers de la mémoire, sous-dossiers compris,
// et les images de conversation (chatimg/, chiffrées comme le fil), sauf les
// copies .bak et les écritures en cours (.tmp). Ne déchiffrer que les pages .md
// laissait le reste chiffré sans plus aucune clé une fois le keyvault retiré.
func allMemFilesForDecrypt() []string {
var out []string
for _, root := range []string{projectsRoot(), chatImgDir()} {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(d.Name(), ".bak") || strings.HasSuffix(d.Name(), ".tmp") {
return nil
}
out = append(out, p)
return nil
})
}
return out
}
// quarantineUnreadable écrit les valeurs indéchiffrables, encore chiffrées, dans
// un dossier daté de LOKI_HOME, avec une copie du keyvault à côté : elles
// restent récupérables si l'ancienne clé est retrouvée.
func quarantineUnreadable(bad []unreadableValue) error {
dir := filepath.Join(LokiHome(), "chiffre-illisible-"+time.Now().Format("20060102-150405"))
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.MarshalIndent(bad, "", " ")
if err != nil {
return err
}
if err := memWriteFileAtomic(filepath.Join(dir, "valeurs.json"), raw, 0o600); err != nil {
return err
}
for _, p := range []string{vaultPathPrimary(), vaultPathBackup()} {
if b, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(filepath.Join(dir, filepath.Base(p)), b, 0o600)
}
}
if b := getBytes(bkState, vaultDBKey); len(b) > 0 {
_ = memWriteFileAtomic(filepath.Join(dir, "keyvault-base.json"), b, 0o600)
}
for _, u := range bad {
fmt.Fprintf(os.Stderr, "[mémoire] illisible avec la clé actuelle, mis en quarantaine : %s / %s (%s)\n", u.Bucket, u.Key, u.Error)
}
fmt.Fprintf(os.Stderr, "[mémoire] %d valeur(s) en quarantaine dans %s\n", len(bad), dir)
return nil
}
// scrubEncryptedResidue retire les .bak encore CHIFFRÉS de la mémoire une fois
// le chiffrement désactivé : la clé est partie avec le keyvault, ils ne pourront
// plus jamais être lus, et ce sont des copies de pages qui viennent d'être
// remises en clair. Pendant symétrique de scrubPlaintextResidue. Les valeurs en
// quarantaine et les snapshots vivent hors de memory/ : jamais touchés. Rien
// n'est fait tant que le chiffrement est actif.
func scrubEncryptedResidue() int {
if memEncActive() {
return 0
}
n := 0
for _, root := range []string{projectsRoot(), chatImgDir()} {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
return nil
}
if b, err := os.ReadFile(p); err == nil && looksEncrypted(b) && os.Remove(p) == nil {
n++
}
return nil
})
}
return n
}
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
// prochain déverrouillage. Ne perd jamais de données.
func resumeMemMigration() {
j, ok := readMigrationJournal()
if !ok {
return
}
switch j.Op {
case "encrypt":
if memEncActive() && memUnlocked() {
if err := reencryptPlaintextPages(); err == nil {
_ = reencryptChatStores()
clearMigrationJournal()
scrubPlaintextResidue()
}
}
case "decrypt":
// Déchiffrement interrompu : si la mémoire est déverrouillée et le drapeau
// encore levé, on termine proprement (écrit le clair restant), puis on
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
// et l'écran de santé signalera le mélange.
// Même chemin complet que DisableMemEncryption : la reprise retirait la clé
// après les seules pages, en laissant les conversations chiffrées sans
// plus aucun moyen de les rouvrir.
if memEncActive() && memUnlocked() {
if err := finishDecryption(); err != nil {
fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err)
}
}
}
}