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>
346 lines
11 KiB
Go
346 lines
11 KiB
Go
package loki
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images
|
|
// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault
|
|
// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en
|
|
// quarantaine, et la désactivation aboutit.
|
|
func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) {
|
|
testHome(t)
|
|
clearMemDEK()
|
|
seedPages(t)
|
|
if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
img := []byte("\x89PNG octets d'image de test")
|
|
ref, err := storeChatImage(img, "image/png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))
|
|
if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) {
|
|
t.Fatal("l'image devrait être chiffrée")
|
|
}
|
|
other := make([]byte, 32)
|
|
other[0] = 7
|
|
alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := putBytes(bkChat, "vieille", alien); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := DisableMemEncryption(); err != nil {
|
|
t.Fatalf("la désactivation aurait dû aboutir : %v", err)
|
|
}
|
|
if memEncActive() {
|
|
t.Fatal("le chiffrement devrait être désactivé")
|
|
}
|
|
if b, _ := os.ReadFile(imgPath); string(b) != string(img) {
|
|
t.Fatalf("image pas déchiffrée : %q", b)
|
|
}
|
|
if v := getBytes(bkChat, "vieille"); len(v) != 0 {
|
|
t.Fatal("la valeur illisible aurait dû quitter la base")
|
|
}
|
|
q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json"))
|
|
if len(q) != 1 {
|
|
t.Fatalf("quarantaine attendue, trouvée : %v", q)
|
|
}
|
|
if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") {
|
|
t.Fatal("la quarantaine doit contenir la valeur illisible")
|
|
}
|
|
if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 {
|
|
t.Fatalf(".bak chiffrés restés derrière : %v", baks)
|
|
}
|
|
}
|
|
|
|
// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé
|
|
// après les seules pages, et les conversations restaient chiffrées pour toujours.
|
|
func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) {
|
|
testHome(t)
|
|
clearMemDEK()
|
|
seedPages(t)
|
|
if _, err := EnableMemEncryption("pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := putBytes(bkChat, "conv:x", enc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeMigrationJournal("decrypt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resumeMemMigration()
|
|
if memEncActive() {
|
|
t.Fatal("la reprise aurait dû terminer le déchiffrement")
|
|
}
|
|
if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` {
|
|
t.Fatalf("conversation restée chiffrée après la reprise : %q", v)
|
|
}
|
|
}
|