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//*.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//*.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) } } } }