Files
Loki/internal/loki/mem_migrate.go
T
Claude fa69dac77f Chiffrement de la mémoire, snapshots, sauvegarde chiffrée en fichier
Reprise d'AJEAN (mem_crypto/mem_vault/mem_store/mem_io/mem_migrate/
mem_snapshots/mem_health/mem_fsutil + backup_bundle), adaptée à Loki.

Chiffrement à enveloppe : une DEK tirée une fois chiffre les données en
AES-256-GCM ; elle est enfermée dans un coffre par une KEK dérivée en
Argon2id. Ce qui ouvre le coffre : la clé de pilotage de l'appareil (le
serveur n'en a que l'empreinte, il ne peut pas ouvrir seul) ou la clé de
récupération donnée une fois. La DEK ne vit qu'en RAM.

Périmètre chiffré, choisi pour Loki : les pages mémoire, les discussions
(journal ET index, qui porte les titres), les blocs archivés au compactage
— du verbatim de conversation — et les trackers. Pas les buckets de
réglages : le coffre lui-même y vit, les chiffrer serait une boucle.

Verrouillé, rien n'est écrasé : putStoreBytes REFUSE d'écrire du clair
par-dessus du chiffré, la liste des discussions se lit vide et les pages
s'affichent « 🔒 chiffré ». Le déverrouillage recharge la discussion et
rattrape ce qui serait resté en clair. Une migration interrompue reprend au
démarrage, un snapshot est pris avant chaque bascule, et aucune donnée n'est
supprimée avant relecture vérifiée de son remplaçant.

Piège corrigé au passage : chiffrer À L'INTÉRIEUR d'une transaction bbolt se
bloquait sur le verrou de la base (memEncActive relit la config, donc la
base). L'état du chiffrement est désormais résolu AVANT la transaction
(memEncoderNow), qui ne reçoit plus qu'un encodeur pur.

Sauvegarde : le paquet chiffré {mémoire, presets, réglages} s'exporte et
s'importe en FICHIER (/api/backup/export, /api/backup/import). La
sauvegarde vers le relais ajean.link n'est pas reprise — c'est le service de
l'auteur amont ; ici le fichier reste chez soi.

Le dossier mémoire n'était déjà plus joignable qu'aux outils mem_* : c'était
la condition de ce chiffrement (un `cat memory/…` aurait rendu du binaire).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:59:10 +00:00

341 lines
11 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é).
if err := decryptAllPages(); err != nil {
return err
}
if err := decryptChatStores(); err != nil {
return err
}
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
// retirer le keyvault et purger la DEK.
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
return err
}
removeVault()
clearMemDEK()
clearMigrationJournal()
return nil
}
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
func decryptAllPages() error {
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à en clair
}
plain, err := decodeMemContent(raw)
if err != nil {
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
return fmt.Errorf("écriture claire %s : %w", p, err)
}
back, err := os.ReadFile(p)
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
}
}
return nil
}
// 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.
if memEncActive() && memUnlocked() {
if err := decryptAllPages(); err == nil {
_ = SetConfigKey("MEM_ENCRYPTED", "")
removeVault()
clearMemDEK()
clearMigrationJournal()
}
}
}
}