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