mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
- 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>
447 lines
16 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|