Files
Loki/internal/loki/mem_migrate_test.go
T
MichaelandClaude Opus 5.5 9ccdc021cb Chiffrement : la discussion active et le mode Code ne deviennent plus illisibles
Activer le chiffrement de la mémoire chiffrait TOUTES les valeurs du
bucket des discussions, y compris des clés que le code lit et écrit en
clair (getStr/putStr) : le pointeur de discussion active, le mode
Chat|Code de chaque discussion, la puce « passer en mode Code ». Relues
comme du charabia, la discussion active devenait introuvable et le mode
Code disparaissait. Les critères, lus en clair eux aussi, s'effaçaient.

- Ces clés-pointeurs restent en clair (plainStoreKey) : ni rechiffrées, ni
  comptées comme « chiffrement incomplet ».
- Celles qu'une base existante a déjà chiffrées sont remises en clair dès
  le déverrouillage, avant le rechargement de la discussion active.
- Les critères passent par putStoreBytes/getStoreBytes : chiffrés comme le
  reste du fil, et lisibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:27:43 +02:00

264 lines
8.0 KiB
Go

package loki
import (
"os"
"strings"
"testing"
)
// seedPages écrit quelques pages en clair dans un $LOKI_HOME de test.
func seedPages(t *testing.T) map[string]string {
t.Helper()
pages := map[string]string{
"projet.md": "# Projet\n\nUn secret très important à ne jamais perdre.\n",
"copine.md": "# Perso\n\nAnniversaire le 3 mars. éàçù ✔\n",
"vide.md": "# Vide\n",
}
for name, content := range pages {
if err := MemAdd(name, content); err != nil {
t.Fatalf("MemAdd %s: %v", name, err)
}
}
// MemAdd maintient désormais l'index MEMORY.md par le code : il existe donc sur
// disque comme les autres pages. On l'ajoute à l'ensemble attendu (avec son
// contenu réel) pour que les comptes et les lectures le prennent en compte.
if idx := MemContent(memIndexFile); idx != "" {
pages[memIndexFile] = idx
}
return pages
}
func TestEnableEncryptReadDisable(t *testing.T) {
testHome(t)
clearMemDEK()
pages := seedPages(t)
rec, err := EnableMemEncryption("motdepasse-fort")
if err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if strings.TrimSpace(rec) == "" {
t.Fatal("clé de récupération vide")
}
if !memEncActive() || !memUnlocked() {
t.Fatal("devrait être actif et déverrouillé après activation")
}
// Sur disque, les pages doivent être chiffrées (magic présent).
for name := range pages {
p, _ := safeMemPath(name)
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatalf("page %s pas chiffrée sur disque", name)
}
}
// Mais lisibles via l'API (déverrouillé).
for name, want := range pages {
if got := MemContent(name); got != want {
t.Fatalf("contenu %s après chiffrement: %q != %q", name, got, want)
}
}
// Verrouillage : plus rien de lisible, mais on ne perd rien.
clearMemDEK()
if got := MemContent("projet.md"); got != "" {
t.Fatal("une page chiffrée verrouillée ne devrait pas être lisible")
}
h := memHealth()
if !h.Locked || h.Readable != 0 || h.Unreadable != len(pages) {
t.Fatalf("santé verrouillée inattendue: %+v", h)
}
// Déverrouillage via mot de passe.
v, _ := loadVault()
dek, _, err := v.unlockWith("motdepasse-fort")
if err != nil {
t.Fatalf("unlock: %v", err)
}
setMemDEK(dek)
if got := MemContent("copine.md"); got != pages["copine.md"] {
t.Fatal("contenu illisible après déverrouillage")
}
// Déchiffrement complet : retour en clair, keyvault retiré.
if err := DisableMemEncryption(); err != nil {
t.Fatalf("DisableMemEncryption: %v", err)
}
if memEncActive() {
t.Fatal("chiffrement devrait être désactivé")
}
for name, want := range pages {
p, _ := safeMemPath(name)
raw, _ := os.ReadFile(p)
if looksEncrypted(raw) {
t.Fatalf("page %s encore chiffrée après déchiffrement", name)
}
if got := MemContent(name); got != want {
t.Fatalf("contenu %s après déchiffrement altéré", name)
}
}
if vaultExists() {
t.Fatal("le keyvault aurait dû être retiré")
}
}
// Récupération : après chiffrement, on doit pouvoir rouvrir avec la clé de
// récupération même sans le mot de passe.
func TestUnlockWithRecoveryAfterEnable(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
rec, err := EnableMemEncryption("pw")
if err != nil {
t.Fatal(err)
}
clearMemDEK()
v, _ := loadVault()
if _, _, err := v.unlockWith(normalizeRecovery(rec)); err != nil {
t.Fatalf("récupération après activation: %v", err)
}
}
// Sécurité : quand la mémoire est chiffrée ET verrouillée, toute écriture doit
// être REFUSÉE (jamais écrire en clair une donnée censée être chiffrée).
func TestWriteRefusedWhenLocked(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
clearMemDEK() // verrouille
if err := MemAdd("nouvelle.md", "contenu"); err == nil {
t.Fatal("MemAdd aurait dû être refusé (mémoire verrouillée)")
}
if err := MemSave("projet.md", "", "écrasé"); err == nil {
t.Fatal("MemSave aurait dû être refusé (mémoire verrouillée)")
}
// Le fichier d'origine ne doit pas avoir été touché en clair.
p, _ := safeMemPath("projet.md")
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatal("la page d'origine a été altérée alors que la mémoire était verrouillée")
}
}
// Après un chiffrement réussi, AUCUN texte en clair ne doit subsister : ni .bak
// clair, ni snapshot « avant-chiffrement ».
func TestNoPlaintextResidueAfterEncrypt(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(memoryDir())
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".bak") {
b, _ := os.ReadFile(memoryDir() + "/" + e.Name())
if !looksEncrypted(b) {
t.Fatalf(".bak en clair résiduel : %s", e.Name())
}
}
}
for _, s := range listSnapshots() {
if strings.Contains(s.Reason, "avant-chiffrement") {
t.Fatalf("snapshot en clair avant-chiffrement resté : %s", s.ID)
}
}
}
// Reprise d'un chiffrement interrompu : journal présent + une page restée en
// clair, mémoire déverrouillée → resume doit finir le chiffrement.
func TestResumeInterruptedEncrypt(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
// Simule une interruption : on repose une page en clair et on rearme le journal.
if err := writeMemPlain("projet.md", []byte("# Projet\n\nclair résiduel\n")); err != nil {
t.Fatal(err)
}
if err := writeMigrationJournal("encrypt"); err != nil {
t.Fatal(err)
}
resumeMemMigration()
p, _ := safeMemPath("projet.md")
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatal("la reprise n'a pas re-chiffré la page restée en clair")
}
if _, busy := readMigrationJournal(); busy {
t.Fatal("le journal aurait dû être levé après reprise réussie")
}
}
// Un round-trip via snapshot : on prend une copie, on casse la mémoire, on
// restaure.
func TestSnapshotRestore(t *testing.T) {
testHome(t)
clearMemDEK()
pages := seedPages(t)
id, err := snapshotMemory("test")
if err != nil || id == "" {
t.Fatalf("snapshot: %v (id=%q)", err, id)
}
// Corrompt une page et en supprime une autre.
pApath, _ := safeMemPath("projet.md")
os.WriteFile(pApath, []byte("CORROMPU"), 0o600)
MemDelete("copine.md")
if err := restoreSnapshot(id); err != nil {
t.Fatalf("restore: %v", err)
}
for name, want := range pages {
if got := MemContent(name); got != want {
t.Fatalf("après restauration %s: %q != %q", name, got, want)
}
}
}
// Activer le chiffrement ne doit pas rendre illisibles les clés-pointeurs du
// bucket des discussions (discussion active, mode Code) ni les critères. Une
// version précédente les chiffrait : la discussion active devenait
// introuvable. Celles déjà chiffrées sont réparées au déverrouillage.
func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
testHome(t)
clearMemDEK()
id := convEnsureActive()
if err := setConvMode(id, "code"); err != nil {
t.Fatal(err)
}
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("discussion active illisible après chiffrement : %q", got)
}
if !convCodeMode() {
t.Fatal("mode Code perdu après chiffrement")
}
if l := critList(id); len(l) != 1 {
t.Fatalf("critères perdus après chiffrement : %+v", l)
}
if !looksEncrypted(getBytes(bkChat, critKey(id))) {
t.Fatal("critères en clair alors que les discussions sont chiffrées")
}
if !bucketFullyEncrypted(bkChat) {
t.Fatal("les clés-pointeurs en clair font croire à un chiffrement incomplet")
}
// Base d'une version précédente : le pointeur a été chiffré.
enc, err := encodeMemContent([]byte(id))
if err != nil {
t.Fatal(err)
}
_ = putBytes(bkChat, ckActive, enc)
healPlainStoreKeys()
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("pointeur chiffré non réparé : %q", got)
}
}