Files
Loki/internal/loki/mem_vault_test.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

177 lines
5.1 KiB
Go

package loki
import (
"bytes"
"os"
"testing"
)
func TestVaultCreateUnlock(t *testing.T) {
testHome(t)
v, dek, err := newVault()
if err != nil {
t.Fatal(err)
}
if err := v.addSecretWrap(dek, wrapPassword, "principal", "hunter2"); err != nil {
t.Fatal(err)
}
got, kind, err := v.unlockWith("hunter2")
if err != nil {
t.Fatalf("unlock: %v", err)
}
if kind != wrapPassword {
t.Fatalf("kind attendu password, obtenu %s", kind)
}
if !bytes.Equal(got, dek) {
t.Fatal("DEK déballée ≠ DEK d'origine")
}
if _, _, err := v.unlockWith("mauvais"); err == nil {
t.Fatal("un mauvais mot de passe n'aurait pas dû ouvrir")
}
}
// Rotation : re-wrapper sous un nouveau mot de passe ne doit PAS changer la DEK.
func TestVaultRotation(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "ancien")
if err := v.addSecretWrap(dek, wrapPassword, "principal", "nouveau"); err != nil {
t.Fatal(err)
}
if _, _, err := v.unlockWith("ancien"); err == nil {
t.Fatal("l'ancien mot de passe aurait dû être remplacé")
}
got, _, err := v.unlockWith("nouveau")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, dek) {
t.Fatal("la rotation a changé la DEK")
}
}
// La clé de récupération rouvre la mémoire même si le wrap mot de passe est retiré.
func TestVaultRecovery(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "motdepasse")
rec, err := newRecoveryKey()
if err != nil {
t.Fatal(err)
}
v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(rec))
// On détruit l'accès mot de passe.
if _, err := v.removeWrap(wrapPassword, ""); err != nil {
t.Fatal(err)
}
got, kind, err := v.unlockWith(normalizeRecovery(rec))
if err != nil {
t.Fatalf("récupération: %v", err)
}
if kind != wrapRecovery || !bytes.Equal(got, dek) {
t.Fatal("la clé de récupération n'a pas rouvert la mémoire")
}
}
func TestVaultRefusesLastWrapRemoval(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "x")
if _, err := v.removeWrap(wrapPassword, ""); err == nil {
t.Fatal("retirer le dernier wrap aurait dû être refusé")
}
}
// Persistance : sauver puis relire depuis les 3 copies, y compris après
// destruction d'une ou deux copies.
func TestVaultPersistRedundancy(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "pw")
if err := saveVault(v); err != nil {
t.Fatal(err)
}
// Détruit la copie primaire et la db, il reste le backup.
os.Remove(vaultPathPrimary())
putBytes(bkState, vaultDBKey, nil)
got, err := loadVault()
if err != nil {
t.Fatalf("load après perte de 2 copies: %v", err)
}
if got == nil {
t.Fatal("keyvault introuvable alors que le backup existe")
}
// loadVault doit avoir réparé les copies manquantes.
if _, err := os.Stat(vaultPathPrimary()); err != nil {
t.Fatal("la copie primaire n'a pas été réparée")
}
d, _, err := got.unlockWith("pw")
if err != nil || !bytes.Equal(d, dek) {
t.Fatal("keyvault relu inutilisable")
}
}
func TestMemDEKState(t *testing.T) {
if memUnlocked() {
clearMemDEK()
}
if _, err := currentDEK(); err == nil {
t.Fatal("currentDEK devrait échouer quand verrouillé")
}
dek, _ := randBytes(memDEKLen)
setMemDEK(dek)
if !memUnlocked() {
t.Fatal("devrait être déverrouillé")
}
got, err := currentDEK()
if err != nil || !bytes.Equal(got, dek) {
t.Fatal("currentDEK incohérent")
}
clearMemDEK()
if memUnlocked() {
t.Fatal("devrait être verrouillé après clear")
}
}
// Zéro-connaissance : le secret détenu par le SERVEUR (clé de pilotage web_key)
// ne doit PAS pouvoir ouvrir le coffre, et stripServerOpenableWraps retire tout
// wrap qui le permettrait.
func TestServerCannotOpenVault(t *testing.T) {
testHome(t)
putStr(bkState, "web_key", "cle-de-pilotage-serveur")
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "mon-mdp-client")
// La clé de pilotage du serveur n'ouvre pas le coffre.
if _, _, err := v.unlockWith("cle-de-pilotage-serveur"); err == nil {
t.Fatal("la clé serveur n'aurait jamais dû ouvrir le coffre")
}
// Si un wrap « clé d'accès » (ouvrable par le serveur) traînait, on le retire.
v.addSecretWrap(dek, wrapE2ERoot, "clé d'accès", "cle-de-pilotage-serveur")
saveVault(v)
if got, _, err := v.unlockWith("cle-de-pilotage-serveur"); err != nil || !bytes.Equal(got, dek) {
t.Fatal("pré-condition: le wrap clé d'accès devrait ouvrir avant strip")
}
stripServerOpenableWraps()
v2, _ := loadVault()
if _, _, err := v2.unlockWith("cle-de-pilotage-serveur"); err == nil {
t.Fatal("après strip, la clé serveur ne doit plus ouvrir le coffre")
}
if _, _, err := v2.unlockWith("mon-mdp-client"); err != nil {
t.Fatal("le mot de passe client doit toujours ouvrir")
}
}
func TestRecoveryKeyFormat(t *testing.T) {
rec, err := newRecoveryKey()
if err != nil {
t.Fatal(err)
}
if normalizeRecovery(rec) == "" {
t.Fatal("clé de récupération vide")
}
if normalizeRecovery("abcd-efgh") != "ABCDEFGH" {
t.Fatalf("normalisation inattendue: %q", normalizeRecovery("abcd-efgh"))
}
}