mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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
This commit is contained in:
27 files changed
+3031
-50
No files matched your search
@@ -34,7 +34,14 @@ Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement).
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
|
||||
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
|
||||
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
|
||||
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
|
||||
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -139,7 +139,8 @@ et modèles compris.
|
||||
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
|
||||
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
@@ -234,6 +235,24 @@ Héritées d'AJEAN :
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers chiffrés en
|
||||
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
|
||||
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
|
||||
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
|
||||
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
|
||||
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
|
||||
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
|
||||
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
|
||||
interrompue reprend au démarrage.
|
||||
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
|
||||
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
|
||||
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
|
||||
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
|
||||
reste chez toi.
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package loki
|
||||
|
||||
// backup_bundle.go — construit et restaure un paquet de sauvegarde
|
||||
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
|
||||
//
|
||||
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
|
||||
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
|
||||
//
|
||||
// Format du blob :
|
||||
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
|
||||
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
|
||||
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
|
||||
// corps : gcmSeal(backupDEK, tar.gz du bundle).
|
||||
//
|
||||
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
|
||||
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
|
||||
|
||||
const backupBodyAAD = "loki-backup-body"
|
||||
|
||||
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
|
||||
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
|
||||
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
|
||||
func buildBundleTar() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
addDir := func(prefix, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil // dossier absent = rien à ajouter
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := addDir("presets", presetsDir()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Réglages (bucket config) sérialisés en JSON.
|
||||
cfg, _ := json.Marshal(ReadConfig())
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
|
||||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
|
||||
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
|
||||
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
|
||||
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
|
||||
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
|
||||
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
|
||||
kv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Write(backupMagic)
|
||||
var lenbuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
|
||||
out.Write(lenbuf[:])
|
||||
out.Write(kv)
|
||||
out.Write(body)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
|
||||
// récupération) pour retrouver la DEK, puis déchiffre le corps.
|
||||
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, backupMagic) {
|
||||
return nil, fmt.Errorf("format de sauvegarde non reconnu")
|
||||
}
|
||||
rest := blob[len(backupMagic):]
|
||||
if len(rest) < 4 {
|
||||
return nil, fmt.Errorf("blob tronqué")
|
||||
}
|
||||
n := binary.BigEndian.Uint32(rest[:4])
|
||||
rest = rest[4:]
|
||||
if int(n) > len(rest) {
|
||||
return nil, fmt.Errorf("entête tronqué")
|
||||
}
|
||||
var v keyVault
|
||||
if err := json.Unmarshal(rest[:n], &v); err != nil {
|
||||
return nil, fmt.Errorf("entête illisible : %w", err)
|
||||
}
|
||||
body := rest[n:]
|
||||
dek, _, err := v.unlockWith(secret)
|
||||
if err != nil {
|
||||
dek, _, err = v.unlockWith(normalizeRecovery(secret))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clé incorrecte")
|
||||
}
|
||||
return gcmOpen(dek, body, []byte(backupBodyAAD))
|
||||
}
|
||||
|
||||
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
|
||||
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
|
||||
func restoreBundleTar(tarData []byte) error {
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var cfgJSON []byte
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
|
||||
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
|
||||
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
|
||||
if strings.Contains(name, "..") {
|
||||
continue // sécurité : jamais de traversée
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case name == "config.json":
|
||||
cfgJSON = b
|
||||
case strings.HasPrefix(name, "memory/"):
|
||||
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
|
||||
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
|
||||
// les rattache au projet par défaut.
|
||||
rel := strings.TrimPrefix(name, "memory/")
|
||||
if !strings.Contains(rel, "/") {
|
||||
rel = defaultProjectSlug + "/" + rel
|
||||
}
|
||||
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(name, "presets/"):
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg map[string]string
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
_ = WriteConfig(cfg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
|
||||
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
|
||||
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
|
||||
func TestBackupBundleRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := EnableMemEncryption("clé-de-pilotage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
t.Fatalf("coffre introuvable après activation: %v", err)
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le paquet ne doit rien laisser voir en clair.
|
||||
if strings.Contains(string(blob), "secret du client") {
|
||||
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
|
||||
}
|
||||
|
||||
// La clé de pilotage l'ouvre…
|
||||
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
|
||||
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
|
||||
}
|
||||
// …la clé de récupération aussi…
|
||||
back, err := openBackupBlob(blob, recovery)
|
||||
if err != nil {
|
||||
t.Fatalf("ouverture avec la clé de récupération: %v", err)
|
||||
}
|
||||
// …et une mauvaise clé, non.
|
||||
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
|
||||
t.Fatal("une clé fausse aurait dû être refusée")
|
||||
}
|
||||
|
||||
// Page effacée puis restaurée depuis le paquet : contenu intact.
|
||||
p, _ := safeMemPath("notes.md")
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreBundleTar(back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := memPageText("notes.md")
|
||||
if !ok || !strings.Contains(got, "secret du client") {
|
||||
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
|
||||
}
|
||||
// Le snapshot de sécurité pris avant restauration doit exister.
|
||||
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
|
||||
t.Fatal("aucun snapshot pris avant la restauration")
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,11 @@ func LoadConversation() {
|
||||
}
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
// getStoreBytesErr : même lecture, en DÉCHIFFRANT au besoin (mémoire
|
||||
// chiffrée). Verrouillé, elle rend (nil, nil) — pas une erreur d'accès :
|
||||
// on démarre alors sur un fil vide sans rien écraser, et tout revient au
|
||||
// déverrouillage.
|
||||
if b, err = getStoreBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
break
|
||||
}
|
||||
retry()
|
||||
@@ -173,6 +177,19 @@ func (c *Conversation) loadFrom(b []byte) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// reloadEncryptedStores recharge la discussion depuis le disque après un
|
||||
// déverrouillage : verrouillée, la lecture au démarrage a rendu un fil vide.
|
||||
// On ne recharge QUE dans ce cas — écraser un fil déjà rempli ferait perdre le
|
||||
// tour en cours, qui n'a pas pu être persisté.
|
||||
func reloadEncryptedStores() {
|
||||
conv.mu.Lock()
|
||||
empty := len(conv.Log) == 0
|
||||
conv.mu.Unlock()
|
||||
if empty {
|
||||
LoadConversation()
|
||||
}
|
||||
}
|
||||
|
||||
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
||||
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
@@ -191,7 +208,10 @@ func (c *Conversation) persist() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
_ = putBytes(bkChat, convKey(id), b)
|
||||
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
|
||||
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
|
||||
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
|
||||
_ = putStoreBytes(bkChat, convKey(id), b)
|
||||
convTouchMeta(id, title, turns)
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ func migrateLegacyUploads(src string) {
|
||||
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
|
||||
// relie un fichier déposé à sa discussion.
|
||||
func convAttachNames(id string) []string {
|
||||
b := getBytes(bkChat, convKey(id))
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -89,8 +89,15 @@ func MemList() []MemPage {
|
||||
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
|
||||
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
|
||||
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
|
||||
// existe, la cacher ferait croire à une perte) avec un titre qui dit
|
||||
// pourquoi son contenu n'apparaît pas.
|
||||
txt, ok := memPageText(e.Name())
|
||||
title := titleOf(txt)
|
||||
if !ok {
|
||||
title = "🔒 (chiffré — mémoire verrouillée)"
|
||||
}
|
||||
out = append(out, MemPage{Name: e.Name(), Title: title})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -125,8 +132,7 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
@@ -246,12 +252,11 @@ func snippetAround(content string, terms []string) string {
|
||||
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
|
||||
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
|
||||
func MemRead(name string, offset, limit int) (string, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
@@ -278,11 +283,8 @@ func MemAdd(name, content string) error {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
|
||||
@@ -298,12 +300,11 @@ func MemAdd(name, content string) error {
|
||||
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
|
||||
|
||||
func MemEdit(name, oldText, newText string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
@@ -323,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
|
||||
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
return os.WriteFile(p, []byte(updated), 0o644)
|
||||
return writeMemFile(name, []byte(updated))
|
||||
}
|
||||
|
||||
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
|
||||
// l'éditeur web.
|
||||
func MemContent(name string) string {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -353,15 +350,8 @@ func MemSave(name, old, content string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
|
||||
|
||||
@@ -84,8 +84,14 @@ func recallIDToSeq(id string) (uint64, bool) {
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
err := withDB(func(d *bolt.DB) error {
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
@@ -108,7 +114,15 @@ func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), raw)
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -131,7 +145,11 @@ func recallGet(id string) (recallBlock, bool) {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
found = json.Unmarshal(v, &blk) == nil
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
||||
}
|
||||
found = json.Unmarshal(dec, &blk) == nil
|
||||
return nil
|
||||
})
|
||||
return blk, found
|
||||
@@ -161,7 +179,11 @@ func recallSearch(query string, limit int) []recallBlock {
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
if json.Unmarshal(v, &blk) != nil {
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
|
||||
@@ -101,7 +101,10 @@ func tagOrphanConversations(slug string) {
|
||||
|
||||
func convIndex() []convMeta {
|
||||
var idx []convMeta
|
||||
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
|
||||
// L'index porte les TITRES des discussions : chiffré comme leur contenu
|
||||
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
|
||||
// rien n'est perdu.
|
||||
if b, ok := getStoreBytes(bkChat, ckIndex); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &idx)
|
||||
}
|
||||
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
|
||||
@@ -115,7 +118,7 @@ func convIndexSave(idx []convMeta) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, ckIndex, b)
|
||||
_ = putStoreBytes(bkChat, ckIndex, b)
|
||||
}
|
||||
|
||||
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
|
||||
@@ -128,7 +131,7 @@ func convEnsureActive() string {
|
||||
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
|
||||
// Migration : le fil unique devient la première discussion. On garde la
|
||||
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
|
||||
_ = putBytes(bkChat, convKey(id), legacy)
|
||||
_ = putStoreBytes(bkChat, convKey(id), legacy)
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
now := time.Now().Unix()
|
||||
@@ -215,7 +218,7 @@ func projectSwitch(slug string) error {
|
||||
}
|
||||
// convIndex rend la plus récemment modifiée en tête.
|
||||
_ = putStr(bkChat, ckActive, list[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(list[0].ID)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,7 +246,7 @@ func convSwitch(id string) error {
|
||||
}
|
||||
conv.persist() // fige la discussion qu'on quitte
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(id)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(id)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -323,6 +326,6 @@ func convDelete(id string) error {
|
||||
return nil
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, next[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package loki
|
||||
|
||||
// mem_crypto.go — primitives de chiffrement de la mémoire.
|
||||
//
|
||||
// Modèle à enveloppe (voir docs/chiffrement-memoire-et-sauvegarde.md) :
|
||||
// - DEK (Data Encryption Key) : 32 octets aléatoires, chiffre chaque page
|
||||
// (AES-256-GCM, nonce aléatoire par écriture). Ne change jamais.
|
||||
// - KEK (Key Encryption Key) : dérivée d'un mot de passe par Argon2id, sert
|
||||
// UNIQUEMENT à (dé)wrapper la DEK (voir mem_vault.go).
|
||||
//
|
||||
// Aucune dépendance exotique : AES-GCM de la stdlib + Argon2id de x/crypto.
|
||||
// La DEK en clair ne touche jamais le disque : elle vit en RAM le temps d'une
|
||||
// session déverrouillée, et sur disque uniquement sous forme wrappée.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
memDEKLen = 32 // AES-256
|
||||
memNonceLen = 12 // taille standard du nonce GCM
|
||||
memSaltLen = 16 // sel Argon2id
|
||||
)
|
||||
|
||||
// Paramètres Argon2id par défaut. Stockés dans chaque wrap du keyvault pour que
|
||||
// de futurs réglages n'invalident pas les coffres existants (on relit toujours
|
||||
// les paramètres écrits, jamais ces constantes, lors du déballage).
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // 64 Mio
|
||||
argonThreads = 1
|
||||
)
|
||||
|
||||
// En-tête d'une page chiffrée. Sert de marqueur de format ET d'AAD (le tag GCM
|
||||
// couvre ainsi la version : impossible de rejouer un blob d'une autre version).
|
||||
var memPageMagic = []byte("LOKIMEMv1")
|
||||
|
||||
// errNotEncrypted signale un contenu qui n'est pas une page chiffrée valide
|
||||
// (mauvais magic) — utile pour distinguer « clair » de « chiffré » à la volée.
|
||||
var errNotEncrypted = errors.New("contenu non chiffré (magic absent)")
|
||||
|
||||
// randBytes renvoie n octets aléatoires cryptographiques.
|
||||
func randBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return nil, fmt.Errorf("source aléatoire indisponible : %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// deriveKEK dérive une clé de 32 octets d'un mot de passe via Argon2id.
|
||||
func deriveKEK(password string, salt []byte, t, m uint32, p uint8) []byte {
|
||||
if t == 0 {
|
||||
t = argonTime
|
||||
}
|
||||
if m == 0 {
|
||||
m = argonMemory
|
||||
}
|
||||
if p == 0 {
|
||||
p = argonThreads
|
||||
}
|
||||
return argon2.IDKey([]byte(password), salt, t, m, p, memDEKLen)
|
||||
}
|
||||
|
||||
// gcmSeal chiffre plaintext avec key (AES-256-GCM) et renvoie nonce||ciphertext.
|
||||
// aad (données authentifiées mais non chiffrées) lie le blob à un contexte.
|
||||
func gcmSeal(key, plaintext, aad []byte) ([]byte, error) {
|
||||
gcm, err := memNewGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce, err := randBytes(memNonceLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, plaintext, aad)
|
||||
out := make([]byte, 0, len(nonce)+len(ct))
|
||||
out = append(out, nonce...)
|
||||
out = append(out, ct...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gcmOpen déchiffre un blob nonce||ciphertext produit par gcmSeal. Une clé
|
||||
// erronée ou un blob altéré renvoie une erreur (jamais un contenu partiel).
|
||||
func gcmOpen(key, blob, aad []byte) ([]byte, error) {
|
||||
gcm, err := memNewGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blob) < memNonceLen {
|
||||
return nil, errors.New("blob trop court")
|
||||
}
|
||||
nonce, ct := blob[:memNonceLen], blob[memNonceLen:]
|
||||
pt, err := gcm.Open(nil, nonce, ct, aad)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("déchiffrement refusé (clé erronée ou donnée altérée) : %w", err)
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
func memNewGCM(key []byte) (cipher.AEAD, error) {
|
||||
if len(key) != memDEKLen {
|
||||
return nil, fmt.Errorf("clé de %d octets, %d attendus", len(key), memDEKLen)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// encPage chiffre le contenu d'une page mémoire : magic || nonce || ciphertext.
|
||||
// Le magic sert d'AAD (authentifié) et de marqueur de format.
|
||||
func encPage(dek, plaintext []byte) ([]byte, error) {
|
||||
sealed, err := gcmSeal(dek, plaintext, memPageMagic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, 0, len(memPageMagic)+len(sealed))
|
||||
out = append(out, memPageMagic...)
|
||||
out = append(out, sealed...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decPage déchiffre une page produite par encPage. Renvoie errNotEncrypted si
|
||||
// le magic manque (donc si on tombe sur une page restée en clair).
|
||||
func decPage(dek, blob []byte) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, memPageMagic) {
|
||||
return nil, errNotEncrypted
|
||||
}
|
||||
return gcmOpen(dek, blob[len(memPageMagic):], memPageMagic)
|
||||
}
|
||||
|
||||
// looksEncrypted indique si un contenu disque est une page chiffrée (magic).
|
||||
func looksEncrypted(blob []byte) bool { return bytes.HasPrefix(blob, memPageMagic) }
|
||||
|
||||
// memCryptoSelfTest vérifie que la chaîne crypto marche RÉELLEMENT sur cette
|
||||
// machine/ce build avant qu'on chiffre la moindre donnée : round-trip DEK sur
|
||||
// une page, plus un cycle KEK wrap/unwrap. Toute anomalie renvoie une erreur, et
|
||||
// l'appelant DOIT alors refuser d'activer le chiffrement (on ne touche pas à la
|
||||
// mémoire tant que ceci n'a pas réussi).
|
||||
func memCryptoSelfTest() error {
|
||||
dek, err := randBytes(memDEKLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sample := []byte("loki auto-test — éàçùô — 0123456789 — ✔")
|
||||
|
||||
enc, err := encPage(dek, sample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test chiffrement page : %w", err)
|
||||
}
|
||||
dec, err := decPage(dek, enc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test déchiffrement page : %w", err)
|
||||
}
|
||||
if !bytes.Equal(dec, sample) {
|
||||
return errors.New("auto-test : round-trip page incohérent")
|
||||
}
|
||||
// Une DEK erronée DOIT échouer (sinon le GCM ne protège rien).
|
||||
badKey, _ := randBytes(memDEKLen)
|
||||
if _, err := decPage(badKey, enc); err == nil {
|
||||
return errors.New("auto-test : une clé erronée a déchiffré (GCM cassé ?!)")
|
||||
}
|
||||
|
||||
// Cycle KEK : wrap la DEK sous un mot de passe, puis déballe.
|
||||
salt, err := randBytes(memSaltLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kek := deriveKEK("mot-de-passe-auto-test", salt, argonTime, argonMemory, argonThreads)
|
||||
box, err := gcmSeal(kek, dek, []byte("loki-vault-selftest"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test wrap DEK : %w", err)
|
||||
}
|
||||
got, err := gcmOpen(kek, box, []byte("loki-vault-selftest"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test unwrap DEK : %w", err)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
return errors.New("auto-test : DEK déballée ≠ DEK d'origine")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemCryptoSelfTest(t *testing.T) {
|
||||
if err := memCryptoSelfTest(); err != nil {
|
||||
t.Fatalf("auto-test crypto échoué : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncDecPageRoundTrip(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
cases := []string{"", "court", "# titre\n\nlignes\néàçù ✔\n", strings.Repeat("x", 100000)}
|
||||
for _, c := range cases {
|
||||
enc, err := encPage(dek, []byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("encPage: %v", err)
|
||||
}
|
||||
if !looksEncrypted(enc) {
|
||||
t.Fatal("looksEncrypted devrait être vrai sur une page chiffrée")
|
||||
}
|
||||
dec, err := decPage(dek, enc)
|
||||
if err != nil {
|
||||
t.Fatalf("decPage: %v", err)
|
||||
}
|
||||
if !bytes.Equal(dec, []byte(c)) {
|
||||
t.Fatalf("round-trip incohérent pour %q", c[:min(len(c), 20)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPagePlaintextDetected(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
if _, err := decPage(dek, []byte("# page en clair")); err != errNotEncrypted {
|
||||
t.Fatalf("attendu errNotEncrypted, obtenu %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPageWrongKeyFails(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
bad, _ := randBytes(memDEKLen)
|
||||
enc, _ := encPage(dek, []byte("secret"))
|
||||
if _, err := decPage(bad, enc); err == nil {
|
||||
t.Fatal("une clé erronée n'aurait pas dû déchiffrer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPageTamperedFails(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
enc, _ := encPage(dek, []byte("secret"))
|
||||
enc[len(enc)-1] ^= 0xFF // altère le tag
|
||||
if _, err := decPage(dek, enc); err == nil {
|
||||
t.Fatal("un blob altéré n'aurait pas dû déchiffrer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileVerified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "sub", "f.bin")
|
||||
data := []byte("contenu vérifié éàç")
|
||||
if err := memWriteFileVerified(p, data, 0o600); err != nil {
|
||||
t.Fatalf("écriture vérifiée: %v", err)
|
||||
}
|
||||
back, _ := os.ReadFile(p)
|
||||
if !bytes.Equal(back, data) {
|
||||
t.Fatal("relecture ≠ écrit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package loki
|
||||
|
||||
// mem_fsutil.go — écritures fichier sûres pour la mémoire : temporaire + fsync +
|
||||
// rename atomique. Aucune donnée n'est jamais écrite « en place » par-dessus une
|
||||
// ancienne : on écrit à côté, on force sur disque, puis on bascule d'un rename
|
||||
// (atomique au niveau du système de fichiers).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// memWriteFileAtomic écrit data dans path de façon atomique et durable. En cas
|
||||
// d'échec, path conserve son ancien contenu (le temporaire est nettoyé).
|
||||
func memWriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil { // durabilité : sur disque avant rename
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// memWriteFileVerified écrit data atomiquement PUIS relit le fichier et compare
|
||||
// octet pour octet. C'est la garantie « ce qui est sur le disque est bien ce
|
||||
// qu'on voulait » avant toute suppression de l'original. En cas d'écart, le
|
||||
// fichier écrit est retiré et une erreur est renvoyée.
|
||||
func memWriteFileVerified(path string, data []byte, perm os.FileMode) error {
|
||||
if err := memWriteFileAtomic(path, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
back, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relecture de vérification impossible : %w", err)
|
||||
}
|
||||
if len(back) != len(data) {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("vérification échouée : %d octets relus, %d écrits", len(back), len(data))
|
||||
}
|
||||
for i := range back {
|
||||
if back[i] != data[i] {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("vérification échouée : octet %d diffère", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package loki
|
||||
|
||||
// mem_health.go — état de santé de la mémoire pour l'UI : chiffrement actif ?
|
||||
// verrouillé ? toutes les pages déchiffrables ? combien de copies du keyvault ?
|
||||
// dernier snapshot ? C'est le tableau de bord qui rassure d'un coup d'œil.
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// MemHealth résume l'état de la mémoire (sérialisé tel quel vers l'UI).
|
||||
type MemHealth struct {
|
||||
Encrypted bool `json:"encrypted"` // chiffrement activé (drapeau)
|
||||
Fully bool `json:"fully"` // TOUT est réellement chiffré au repos (pages + conversations)
|
||||
Locked bool `json:"locked"` // chiffré mais DEK absente
|
||||
Pages int `json:"pages"` // nb de pages
|
||||
Readable int `json:"readable"` // pages déchiffrables (ou claires)
|
||||
Unreadable int `json:"unreadable"` // pages illisibles (chiffrées + verrouillé, ou corrompues)
|
||||
Mixed bool `json:"mixed"` // mélange clair/chiffré (migration en cours ?)
|
||||
VaultCopies int `json:"vault_copies"` // nb de copies du keyvault présentes (sur 3)
|
||||
HasRecovery bool `json:"has_recovery"` // une clé de récupération est enregistrée
|
||||
Snapshots int `json:"snapshots"` // nb de snapshots locaux
|
||||
LastSnapshot string `json:"last_snapshot"` // ID du snapshot le plus récent
|
||||
MigrationBusy bool `json:"migration_busy"` // une migration est en cours (journal présent)
|
||||
}
|
||||
|
||||
// memHealth calcule l'état courant.
|
||||
func memHealth() MemHealth {
|
||||
h := MemHealth{Encrypted: memEncActive(), Locked: memEncActive() && !memUnlocked()}
|
||||
|
||||
clair, chiffre := 0, 0
|
||||
for _, name := range mdPages() {
|
||||
h.Pages++
|
||||
p, _ := safeMemPath(name)
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
h.Unreadable++
|
||||
continue
|
||||
}
|
||||
if looksEncrypted(raw) {
|
||||
chiffre++
|
||||
if _, err := decodeMemContent(raw); err == nil {
|
||||
h.Readable++
|
||||
} else {
|
||||
h.Unreadable++
|
||||
}
|
||||
} else {
|
||||
clair++
|
||||
h.Readable++
|
||||
}
|
||||
}
|
||||
h.Mixed = clair > 0 && chiffre > 0
|
||||
|
||||
// Fully : la case « activer le chiffrement » ne doit être cochée QUE si TOUT est
|
||||
// réellement chiffré au repos — aucune page en clair ET aucune valeur de
|
||||
// conversation en clair. Vérifiable même verrouillé (looksEncrypted lit le magic
|
||||
// sans la DEK). Un chiffrement partiel/raté laisse donc la case DÉCOCHÉE.
|
||||
if h.Encrypted {
|
||||
full := clair == 0 && h.Pages == chiffre // toutes les pages chiffrées (0 en clair)
|
||||
if full {
|
||||
for _, b := range encryptedBuckets {
|
||||
if !bucketFullyEncrypted(b) {
|
||||
full = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Fully = full
|
||||
}
|
||||
|
||||
// Copies du keyvault présentes.
|
||||
if _, err := os.Stat(vaultPathPrimary()); err == nil {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if _, err := os.Stat(vaultPathBackup()); err == nil {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if len(getBytes(bkState, vaultDBKey)) > 0 {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if v, _ := loadVault(); v != nil {
|
||||
h.HasRecovery = v.hasKind(wrapRecovery)
|
||||
}
|
||||
|
||||
snaps := listSnapshots()
|
||||
h.Snapshots = len(snaps)
|
||||
if len(snaps) > 0 {
|
||||
h.LastSnapshot = snaps[0].ID
|
||||
}
|
||||
_, h.MigrationBusy = readMigrationJournal()
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package loki
|
||||
|
||||
// mem_io.go — couche d'accès transparente aux pages mémoire. C'est le SEUL
|
||||
// endroit qui sait si une page est chiffrée ou non : chat_memory.go passe par
|
||||
// ici et n'a jamais à s'en soucier.
|
||||
//
|
||||
// Choix d'implémentation : le NOM du fichier ne change pas (toujours foo.md).
|
||||
// C'est le CONTENU qui est chiffré en place, précédé d'un magic (voir
|
||||
// mem_crypto.go) qui rend chaque fichier auto-descriptif. Avantage : rien à
|
||||
// changer dans le listing, la recherche ou la validation de chemins — la
|
||||
// détection se fait sur le contenu, pas sur l'extension.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errMemLocked : page chiffrée alors que la mémoire est verrouillée (DEK absente).
|
||||
var errMemLocked = errors.New("page chiffrée, mémoire verrouillée")
|
||||
|
||||
// memEncActive indique si le chiffrement de la mémoire est activé (réglage
|
||||
// MEM_ENCRYPTED). Indépendant de l'état verrouillé/déverrouillé.
|
||||
func memEncActive() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_ENCRYPTED"])) {
|
||||
case "1", "on", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decodeMemContent transforme le contenu BRUT d'un fichier en texte lisible :
|
||||
// - pas chiffré (pas de magic) : renvoyé tel quel.
|
||||
// - chiffré + DEK en RAM : déchiffré.
|
||||
// - chiffré + verrouillé : errMemLocked.
|
||||
func decodeMemContent(raw []byte) ([]byte, error) {
|
||||
if !looksEncrypted(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return decPage(dek, raw)
|
||||
}
|
||||
|
||||
// memReadPage lit une page par son nom et renvoie son texte clair. Erreur si le
|
||||
// fichier est absent, ou chiffré alors que la mémoire est verrouillée.
|
||||
func memReadPage(name string) ([]byte, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeMemContent(raw)
|
||||
}
|
||||
|
||||
// memPageText lit une page et renvoie (texte, lisible). Ne renvoie jamais
|
||||
// d'erreur : une page chiffrée non déchiffrable donne readable=false (utile pour
|
||||
// lister/chercher sans planter quand la mémoire est verrouillée).
|
||||
func memPageText(name string) (string, bool) {
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// encodeMemContent prépare le contenu à écrire sur disque selon l'état du
|
||||
// chiffrement : chiffré si activé ET déverrouillé, clair sinon. Renvoie une
|
||||
// erreur si le chiffrement est activé mais la mémoire verrouillée (on refuse
|
||||
// alors d'écrire, plutôt que d'écrire en clair une donnée censée être chiffrée).
|
||||
func encodeMemContent(plain []byte) ([]byte, error) {
|
||||
if !memEncActive() {
|
||||
return plain, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return encPage(dek, plain)
|
||||
}
|
||||
|
||||
// memEncoderNow résout l'état du chiffrement (drapeau + DEK) AVANT toute
|
||||
// transaction, et renvoie un encodeur PUR. C'est la seule façon de chiffrer une
|
||||
// valeur écrite À L'INTÉRIEUR d'une transaction bbolt : la base n'est pas
|
||||
// rentrante — memEncActive lit la configuration, donc rouvrirait la base et se
|
||||
// bloquerait sur son propre verrou (vécu sur l'archivage des blocs recall).
|
||||
func memEncoderNow() (func([]byte) ([]byte, error), error) {
|
||||
if !memEncActive() {
|
||||
return func(p []byte) ([]byte, error) { return p, nil }, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return func(p []byte) ([]byte, error) { return encPage(dek, p) }, nil
|
||||
}
|
||||
|
||||
// writeMemFile écrit (crée ou remplace) une page de façon sûre : encode selon
|
||||
// l'état du chiffrement, garde un .bak de la version précédente, écrit de façon
|
||||
// atomique et vérifiée. Un échec laisse l'ancienne version intacte.
|
||||
func writeMemFile(name string, plain []byte) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := encodeMemContent(plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// .bak de la version précédente (best-effort) : filet en cas de pépin juste
|
||||
// après le rename.
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
return memWriteFileVerified(p, out, 0o600)
|
||||
}
|
||||
|
||||
// writeMemPlain écrit une page EN CLAIR quel que soit l'état du chiffrement.
|
||||
// Réservé au déchiffrement (DisableMemEncryption) : il faut pouvoir poser du
|
||||
// clair pendant que le drapeau MEM_ENCRYPTED est encore levé, sinon writeMemFile
|
||||
// re-chiffrerait ce qu'on cherche justement à déchiffrer.
|
||||
func writeMemPlain(name string, plain []byte) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
return memWriteFileVerified(p, plain, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package loki
|
||||
|
||||
// mem_snapshots.go — filet de sécurité local : des copies horodatées du dossier
|
||||
// memory/, indépendantes du chiffrement et du relais. Un bug qui corromprait la
|
||||
// mémoire courante se rejoue depuis un snapshot antérieur, même pour un
|
||||
// utilisateur non abonné.
|
||||
//
|
||||
// Les snapshots copient TELS QUELS les fichiers de memory/ (clairs ou chiffrés,
|
||||
// keyvault compris) : ce sont des copies fidèles, jamais une transformation.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Nombre de snapshots conservés (rotation). Au-delà, les plus anciens sont
|
||||
// purgés — sauf ceux marqués « pré-bascule », gardés plus longtemps.
|
||||
const memSnapshotsKept = 20
|
||||
|
||||
func snapshotsRoot() string { return filepath.Join(LokiHome(), "backups", "memory") }
|
||||
|
||||
// MemSnapshot décrit une copie horodatée.
|
||||
type MemSnapshot struct {
|
||||
ID string `json:"id"` // nom de dossier (horodaté)
|
||||
Reason string `json:"reason"` // pourquoi elle a été prise
|
||||
When time.Time `json:"when"`
|
||||
Pages int `json:"pages"` // nb de fichiers .md/.md.enc
|
||||
}
|
||||
|
||||
// snapshotMemory prend une copie du dossier memory/. reason est un libellé court
|
||||
// (ex. "auto", "avant-chiffrement"). Renvoie l'ID de la copie. Ne fait rien (ID
|
||||
// vide) si le dossier mémoire est absent ou vide.
|
||||
func snapshotMemory(reason string) (string, error) {
|
||||
entries, err := os.ReadDir(memoryDir())
|
||||
if err != nil || len(entries) == 0 {
|
||||
return "", nil // rien à sauvegarder
|
||||
}
|
||||
id := time.Now().UTC().Format("20060102-150405") + "-" + sanitizeReason(reason)
|
||||
dst := filepath.Join(snapshotsRoot(), id)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue // memory/ est plat ; on ignore un éventuel sous-dossier
|
||||
}
|
||||
src := filepath.Join(memoryDir(), e.Name())
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lecture %s : %w", e.Name(), err)
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(dst, e.Name()), b, 0o600); err != nil {
|
||||
return "", fmt.Errorf("copie %s : %w", e.Name(), err)
|
||||
}
|
||||
}
|
||||
pruneSnapshots()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// listSnapshots renvoie les snapshots existants, du plus récent au plus ancien.
|
||||
func listSnapshots() []MemSnapshot {
|
||||
entries, err := os.ReadDir(snapshotsRoot())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []MemSnapshot
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := e.Name()
|
||||
pages := 0
|
||||
if inner, err := os.ReadDir(filepath.Join(snapshotsRoot(), id)); err == nil {
|
||||
for _, f := range inner {
|
||||
n := strings.ToLower(f.Name())
|
||||
if strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".md.enc") {
|
||||
pages++
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, MemSnapshot{ID: id, Reason: reasonOf(id), When: whenOf(id), Pages: pages})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// restoreSnapshot remplace le contenu de memory/ par celui d'un snapshot. Pour
|
||||
// ne rien perdre en cas d'erreur, on prend D'ABORD un snapshot de sécurité de
|
||||
// l'état courant, puis on écrit chaque fichier de façon vérifiée avant de retirer
|
||||
// les fichiers surnuméraires.
|
||||
func restoreSnapshot(id string) error {
|
||||
src := filepath.Join(snapshotsRoot(), filepath.Base(id))
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("snapshot introuvable : %w", err)
|
||||
}
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
restored := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(src, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(memoryDir(), e.Name()), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
restored[e.Name()] = true
|
||||
}
|
||||
// Retire de memory/ ce qui n'est pas dans le snapshot (fichiers apparus après).
|
||||
if cur, err := os.ReadDir(memoryDir()); err == nil {
|
||||
for _, f := range cur {
|
||||
if !f.IsDir() && !restored[f.Name()] {
|
||||
os.Remove(filepath.Join(memoryDir(), f.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneSnapshots garde les memSnapshotsKept plus récents, en conservant en plus
|
||||
// tous les snapshots « avant-* » (pris avant une opération sensible).
|
||||
func pruneSnapshots() {
|
||||
all := listSnapshots()
|
||||
if len(all) <= memSnapshotsKept {
|
||||
return
|
||||
}
|
||||
kept := 0
|
||||
for _, s := range all {
|
||||
if strings.Contains(s.Reason, "avant") {
|
||||
continue // on garde les snapshots de sécurité
|
||||
}
|
||||
kept++
|
||||
if kept > memSnapshotsKept {
|
||||
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Petits utilitaires d'ID --------------------------------------------------
|
||||
|
||||
func sanitizeReason(reason string) string {
|
||||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||||
var b strings.Builder
|
||||
for _, r := range reason {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' || r == '_' {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "snap"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func reasonOf(id string) string {
|
||||
// format : 20060102-150405-<reason>
|
||||
parts := strings.SplitN(id, "-", 3)
|
||||
if len(parts) == 3 {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whenOf(id string) time.Time {
|
||||
parts := strings.SplitN(id, "-", 3)
|
||||
if len(parts) >= 2 {
|
||||
if t, err := time.Parse("20060102-150405", parts[0]+"-"+parts[1]); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package loki
|
||||
|
||||
// mem_store.go — chiffrement transparent de VALEURS de la base (bbolt), pour
|
||||
// étendre le chiffrement au-delà des pages mémoire : conversations et archives
|
||||
// contiennent aussi des infos sensibles.
|
||||
//
|
||||
// Même enveloppe que les pages (encPage/decPage + magic) : une valeur est
|
||||
// chiffrée quand le chiffrement est actif ET la mémoire déverrouillée, en clair
|
||||
// sinon. La détection se fait sur le magic, donc clair et chiffré cohabitent
|
||||
// (utile pendant une migration ou un état verrouillé).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
|
||||
var errStoreLocked = errors.New("valeur chiffrée, mémoire verrouillée")
|
||||
|
||||
// putStoreBytes écrit une valeur, chiffrée si le chiffrement est actif ET
|
||||
// déverrouillé. Si actif mais verrouillé, renvoie errStoreLocked SANS écrire
|
||||
// (on ne remplace jamais une valeur chiffrée par du clair).
|
||||
func putStoreBytes(bucket, key string, plain []byte) error {
|
||||
out, err := encodeMemContent(plain)
|
||||
if err != nil {
|
||||
return errStoreLocked
|
||||
}
|
||||
return putBytes(bucket, key, out)
|
||||
}
|
||||
|
||||
// getStoreBytes lit une valeur et la déchiffre au besoin. Renvoie (nil, false)
|
||||
// si absente, ou si chiffrée alors que la mémoire est verrouillée.
|
||||
func getStoreBytes(bucket, key string) ([]byte, bool) {
|
||||
raw := getBytes(bucket, key)
|
||||
if len(raw) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
// storeBytes : getStoreBytes réduit à sa valeur, pour les appelants qui
|
||||
// traitent déjà « absent » et « illisible » de la même façon (un fil vide).
|
||||
func storeBytes(bucket, key string) []byte {
|
||||
b, _ := getStoreBytes(bucket, key)
|
||||
return b
|
||||
}
|
||||
|
||||
// getStoreBytesErr fait la même lecture que getStoreBytes mais SANS avaler
|
||||
// l'erreur d'accès (getBytes, lui, la jette — c'est le piège documenté sur
|
||||
// getBytesErr : « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas dire
|
||||
// la même chose). Une valeur absente reste (nil, nil) — err n'est non-nil QUE
|
||||
// si la base elle-même n'a pas pu être ouverte/lue (verrou bbolt disputé, disque
|
||||
// indisponible…), pas pour une clé qui n'existe simplement pas encore.
|
||||
func getStoreBytesErr(bucket, key string) ([]byte, error) {
|
||||
raw, err := getBytesErr(bucket, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
// Chiffré mais verrouillé (ou payload corrompu) : légitime, pas un échec
|
||||
// d'ACCÈS — même repli que getStoreBytes.
|
||||
return nil, nil
|
||||
}
|
||||
return dec, nil
|
||||
}
|
||||
|
||||
// putStoreJSON sérialise puis écrit (chiffré si actif+déverrouillé).
|
||||
func putStoreJSON(bucket, key string, v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return putStoreBytes(bucket, key, b)
|
||||
}
|
||||
|
||||
// getStoreJSON lit puis décode (déchiffre au besoin). false si absent/verrouillé.
|
||||
func getStoreJSON(bucket, key string, dst any) bool {
|
||||
b, ok := getStoreBytes(bucket, key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal(b, dst) == nil
|
||||
}
|
||||
|
||||
// reencryptBucket (re)chiffre toutes les valeurs d'un bucket encore en clair,
|
||||
// avec vérification par relecture. Sûr à rejouer. Exige la DEK en RAM.
|
||||
func reencryptBucket(bucket string) error {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
if err := putStoreBytes(bucket, k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
back, ok := getStoreBytes(bucket, k)
|
||||
if !ok || string(back) != v {
|
||||
return errors.New("vérification post-chiffrement échouée pour " + bucket + "/" + k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketFullyEncrypted indique qu'AUCUNE valeur du bucket n'est en clair (toutes
|
||||
// portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement
|
||||
// est réellement complet.
|
||||
func bucketFullyEncrypted(bucket string) bool {
|
||||
for _, v := range allKV(bucket) {
|
||||
if v != "" && !looksEncrypted([]byte(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// encryptedBuckets : les buckets dont les valeurs sont chiffrées quand le
|
||||
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
|
||||
// discussion active et le journal de chacune), les blocs archivés au compactage
|
||||
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
|
||||
// les trackers (bkTracker, 3ᵉ type de mémoire).
|
||||
//
|
||||
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
|
||||
// portent des réglages, pas des données personnelles — et surtout le coffre
|
||||
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
|
||||
// boucle : plus moyen de déverrouiller quoi que ce soit.
|
||||
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker}
|
||||
|
||||
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
|
||||
func reencryptChatStores() error {
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := reencryptBucket(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
|
||||
func decryptChatStores() error {
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := decryptBucket(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
||||
// la DEK en RAM. Sûr à rejouer.
|
||||
func decryptBucket(bucket string) error {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if !looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := putBytes(bucket, k, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La case « chiffré » (health.Fully) ne doit être vraie QUE si TOUT est chiffré.
|
||||
func TestFullyReflectsRealState(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("a.md", "# A\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[]}`))
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !memHealth().Fully {
|
||||
t.Fatal("après chiffrement complet, Fully devrait être vrai")
|
||||
}
|
||||
// Une page en clair réapparue → Fully faux.
|
||||
p, _ := safeMemPath("a.md")
|
||||
if err := os.WriteFile(p, []byte("# clair"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memHealth().Fully {
|
||||
t.Fatal("une page en clair aurait dû rendre Fully faux")
|
||||
}
|
||||
// Remets la page chiffrée, salis une discussion → Fully faux aussi.
|
||||
if err := writeMemFile("a.md", []byte("# A\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, convKey("c2"), []byte("journal en clair"))
|
||||
if memHealth().Fully {
|
||||
t.Fatal("une discussion en clair aurait dû rendre Fully faux")
|
||||
}
|
||||
}
|
||||
|
||||
// Les discussions (journal + index) et les blocs archivés doivent être chiffrés
|
||||
// à l'activation, illisibles verrouillé, et intacts après déchiffrement.
|
||||
func TestConversationEncryptionLifecycle(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
|
||||
// Une discussion avec son journal et son entrée d'index.
|
||||
_ = putStoreBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[{"seq":1}],"messages":[]}`))
|
||||
convIndexSave([]convMeta{{ID: "c1", Title: "Client confidentiel", Created: 1, Updated: 2}})
|
||||
// Une page mémoire, pour activer le chiffrement (il chiffre tout).
|
||||
if err := MemAdd("note.md", "# Note\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sur disque, journal ET index doivent porter le magic.
|
||||
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
|
||||
t.Fatal("journal de discussion non chiffré sur disque")
|
||||
}
|
||||
if raw := getBytes(bkChat, ckIndex); !looksEncrypted(raw) {
|
||||
t.Fatal("index des discussions non chiffré sur disque")
|
||||
}
|
||||
|
||||
// Déverrouillé : lisible.
|
||||
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
|
||||
t.Fatalf("index illisible alors que déverrouillé: %+v", idx)
|
||||
}
|
||||
|
||||
// Verrouillé : plus rien de lisible, mais rien de perdu.
|
||||
clearMemDEK()
|
||||
if idx := convIndex(); len(idx) != 0 {
|
||||
t.Fatal("les discussions devraient être masquées quand verrouillé")
|
||||
}
|
||||
if b := storeBytes(bkChat, convKey("c1")); len(b) != 0 {
|
||||
t.Fatal("le journal devrait être illisible quand verrouillé")
|
||||
}
|
||||
// putStoreBytes doit REFUSER d'écrire du clair quand verrouillé.
|
||||
if err := putStoreBytes(bkChat, convKey("c1"), []byte("clair")); err != errStoreLocked {
|
||||
t.Fatalf("écriture clair aurait dû être refusée, obtenu %v", err)
|
||||
}
|
||||
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
|
||||
t.Fatal("le blob chiffré a été écrasé alors que verrouillé")
|
||||
}
|
||||
|
||||
// Re-déverrouille et déchiffre : tout revient en clair et intact.
|
||||
v, _ := loadVault()
|
||||
dek, _, err := v.unlockWith("pw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMemDEK(dek)
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw := getBytes(bkChat, convKey("c1")); looksEncrypted(raw) {
|
||||
t.Fatal("journal encore chiffré après déchiffrement")
|
||||
}
|
||||
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
|
||||
t.Fatal("index perdu après déchiffrement")
|
||||
}
|
||||
}
|
||||
|
||||
// Un bloc archivé au compactage est du verbatim de conversation : chiffré comme
|
||||
// le reste, et hors de portée tant que la mémoire est verrouillée.
|
||||
func TestRecallBlocksFollowEncryption(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("note.md", "# Note\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := archiveRecallBlock("tool: lecture", "tool", "contenu très bavard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blk, ok := recallGet(id); !ok || blk.Content != "contenu très bavard" {
|
||||
t.Fatal("bloc illisible alors que déverrouillé")
|
||||
}
|
||||
if hits := recallSearch("bavard", 5); len(hits) != 1 {
|
||||
t.Fatalf("la recherche devrait trouver le bloc déverrouillé, obtenu %d", len(hits))
|
||||
}
|
||||
clearMemDEK()
|
||||
if _, ok := recallGet(id); ok {
|
||||
t.Fatal("bloc lisible alors que verrouillé")
|
||||
}
|
||||
if hits := recallSearch("bavard", 5); len(hits) != 0 {
|
||||
t.Fatal("la recherche ne devrait rien trouver quand verrouillé")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package loki
|
||||
|
||||
// mem_vault.go — le keyvault : la DEK de la mémoire, wrappée par une ou
|
||||
// plusieurs KEK (mot de passe, clé de récupération, mot de passe app), et gardée
|
||||
// en RAM le temps d'une session déverrouillée.
|
||||
//
|
||||
// Le keyvault est le POINT DE DÉFAILLANCE UNIQUE : le perdre = perdre la
|
||||
// mémoire. Il est donc écrit en TROIS copies indépendantes, chacune suffisante
|
||||
// seule, et contient plusieurs wraps indépendants de la même DEK. Perdre le mot
|
||||
// de passe n'est pas fatal tant qu'il reste la clé de récupération.
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Types de wrap. Chaque wrap re-chiffre la MÊME DEK sous une KEK différente.
|
||||
const (
|
||||
wrapPassword = "password" // mot de passe mémoire choisi par l'utilisateur
|
||||
wrapRecovery = "recovery" // clé de récupération (secours anti-perte)
|
||||
wrapE2ERoot = "e2e-root" // racine E2E de l'abonné (mot de passe app)
|
||||
)
|
||||
|
||||
// checkPlain est le témoin scellé par la DEK dans le keyvault : le déballer
|
||||
// prouve que la DEK reconstituée est la bonne (détecte un wrap corrompu).
|
||||
var checkPlain = []byte("loki-memoire-ok-v1")
|
||||
|
||||
type kekParams struct {
|
||||
T uint32 `json:"t"`
|
||||
M uint32 `json:"m"`
|
||||
P uint8 `json:"p"`
|
||||
}
|
||||
|
||||
type vaultWrap struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Salt string `json:"salt"` // base64 du sel Argon2id propre à ce wrap
|
||||
KDF kekParams `json:"kdf"`
|
||||
Box string `json:"box"` // base64 de gcmSeal(KEK, DEK, aad)
|
||||
}
|
||||
|
||||
type keyVault struct {
|
||||
V int `json:"v"`
|
||||
Check string `json:"check"` // base64 de gcmSeal(DEK, checkPlain)
|
||||
Wraps []vaultWrap `json:"wraps"`
|
||||
}
|
||||
|
||||
func vaultWrapAAD(kind string) []byte { return []byte("loki-vault/" + kind) }
|
||||
|
||||
// --- Emplacements du keyvault (3 copies indépendantes) -----------------------
|
||||
|
||||
// Le keyvault est GLOBAL (une seule DEK pour la mémoire de TOUS les projets) : il
|
||||
// vit à la racine de LOKI_HOME, pas dans le dossier d'un projet. L'ancien
|
||||
// emplacement (memory/.keyvault) est migré par ensureDefaultProject.
|
||||
func vaultPathPrimary() string { return filepath.Join(LokiHome(), ".keyvault") }
|
||||
func vaultPathBackup() string { return filepath.Join(LokiHome(), ".keyvault.bak") }
|
||||
|
||||
const vaultDBKey = "mem_keyvault"
|
||||
|
||||
// --- État en RAM de la DEK ----------------------------------------------------
|
||||
|
||||
var (
|
||||
memKeyMu sync.RWMutex
|
||||
memDEK []byte // nil = mémoire verrouillée
|
||||
)
|
||||
|
||||
// memUnlocked indique si la DEK est chargée en RAM (mémoire déverrouillée).
|
||||
func memUnlocked() bool {
|
||||
memKeyMu.RLock()
|
||||
defer memKeyMu.RUnlock()
|
||||
return memDEK != nil
|
||||
}
|
||||
|
||||
// setMemDEK charge la DEK en RAM (copie défensive).
|
||||
func setMemDEK(dek []byte) {
|
||||
memKeyMu.Lock()
|
||||
memDEK = append([]byte(nil), dek...)
|
||||
memKeyMu.Unlock()
|
||||
}
|
||||
|
||||
// clearMemDEK purge la DEK de la RAM (verrouille la mémoire).
|
||||
func clearMemDEK() {
|
||||
memKeyMu.Lock()
|
||||
for i := range memDEK {
|
||||
memDEK[i] = 0
|
||||
}
|
||||
memDEK = nil
|
||||
memKeyMu.Unlock()
|
||||
}
|
||||
|
||||
// currentDEK renvoie la DEK en RAM, ou une erreur si la mémoire est verrouillée.
|
||||
func currentDEK() ([]byte, error) {
|
||||
memKeyMu.RLock()
|
||||
defer memKeyMu.RUnlock()
|
||||
if memDEK == nil {
|
||||
return nil, errors.New("mémoire verrouillée — aucune clé chargée")
|
||||
}
|
||||
return append([]byte(nil), memDEK...), nil
|
||||
}
|
||||
|
||||
// --- Construction et manipulation du keyvault --------------------------------
|
||||
|
||||
// newVault crée un keyvault autour d'une DEK fraîche : aucun wrap encore, mais
|
||||
// le témoin est posé. Renvoie aussi la DEK en clair (à garder en RAM, jamais sur
|
||||
// disque).
|
||||
func newVault() (*keyVault, []byte, error) {
|
||||
dek, err := randBytes(memDEKLen)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
check, err := gcmSeal(dek, checkPlain, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &keyVault{V: 1, Check: base64.StdEncoding.EncodeToString(check)}, dek, nil
|
||||
}
|
||||
|
||||
// addSecretWrap (ou remplace) un wrap de la DEK sous un secret (mot de passe ou
|
||||
// clé de récupération). kind identifie le wrap ; un même kind+label écrase le
|
||||
// précédent (rotation de mot de passe = re-wrap, sans toucher aux pages).
|
||||
func (v *keyVault) addSecretWrap(dek []byte, kind, label, secret string) error {
|
||||
salt, err := randBytes(memSaltLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kp := kekParams{T: argonTime, M: argonMemory, P: argonThreads}
|
||||
kek := deriveKEK(secret, salt, kp.T, kp.M, kp.P)
|
||||
box, err := gcmSeal(kek, dek, vaultWrapAAD(kind))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := vaultWrap{
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||
KDF: kp,
|
||||
Box: base64.StdEncoding.EncodeToString(box),
|
||||
}
|
||||
for i := range v.Wraps {
|
||||
if v.Wraps[i].Kind == kind && v.Wraps[i].Label == label {
|
||||
v.Wraps[i] = w
|
||||
return nil
|
||||
}
|
||||
}
|
||||
v.Wraps = append(v.Wraps, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeWrap retire les wraps d'un kind (révocation). Renvoie le nombre retiré.
|
||||
// Refuse de vider le dernier wrap (sinon la DEK deviendrait irrécupérable).
|
||||
func (v *keyVault) removeWrap(kind, label string) (int, error) {
|
||||
kept := v.Wraps[:0:0]
|
||||
removed := 0
|
||||
for _, w := range v.Wraps {
|
||||
if w.Kind == kind && (label == "" || w.Label == label) {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, w)
|
||||
}
|
||||
if removed > 0 && len(kept) == 0 {
|
||||
return 0, errors.New("refus : ce serait le dernier accès à la mémoire")
|
||||
}
|
||||
v.Wraps = kept
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// unlockWith tente de reconstituer la DEK à partir d'un secret : essaie chaque
|
||||
// wrap, et ne renvoie la DEK que si le témoin la valide. Renvoie le kind du wrap
|
||||
// qui a marché (utile pour l'UI).
|
||||
func (v *keyVault) unlockWith(secret string) ([]byte, string, error) {
|
||||
if len(v.Wraps) == 0 {
|
||||
return nil, "", errors.New("keyvault sans aucun wrap")
|
||||
}
|
||||
for _, w := range v.Wraps {
|
||||
salt, err := base64.StdEncoding.DecodeString(w.Salt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
box, err := base64.StdEncoding.DecodeString(w.Box)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
kek := deriveKEK(secret, salt, w.KDF.T, w.KDF.M, w.KDF.P)
|
||||
dek, err := gcmOpen(kek, box, vaultWrapAAD(w.Kind))
|
||||
if err != nil {
|
||||
continue // mauvais secret pour ce wrap, on essaie le suivant
|
||||
}
|
||||
if err := v.validate(dek); err != nil {
|
||||
continue
|
||||
}
|
||||
return dek, w.Kind, nil
|
||||
}
|
||||
return nil, "", errors.New("secret incorrect (aucun wrap ne correspond)")
|
||||
}
|
||||
|
||||
// validate confirme qu'une DEK candidate ouvre bien le témoin du keyvault.
|
||||
func (v *keyVault) validate(dek []byte) error {
|
||||
check, err := base64.StdEncoding.DecodeString(v.Check)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
got, err := gcmOpen(dek, check, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(got, checkPlain) != 1 {
|
||||
return errors.New("témoin du keyvault invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *keyVault) hasKind(kind string) bool {
|
||||
for _, w := range v.Wraps {
|
||||
if w.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Persistance redondée -----------------------------------------------------
|
||||
|
||||
// saveVault écrit le keyvault dans ses TROIS copies. Réussit si AU MOINS une
|
||||
// copie a pu être écrite (on ne veut jamais bloquer sur une copie inaccessible),
|
||||
// mais renvoie l'erreur agrégée sinon.
|
||||
func saveVault(v *keyVault) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []string
|
||||
okAny := false
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err == nil {
|
||||
if err := memWriteFileAtomic(vaultPathPrimary(), data, 0o600); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "primaire: "+err.Error())
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, "memoryDir: "+err.Error())
|
||||
}
|
||||
if err := memWriteFileAtomic(vaultPathBackup(), data, 0o600); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "backup: "+err.Error())
|
||||
}
|
||||
if err := putBytes(bkState, vaultDBKey, data); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "db: "+err.Error())
|
||||
}
|
||||
if !okAny {
|
||||
return fmt.Errorf("aucune copie du keyvault écrite : %s", strings.Join(errs, " ; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadVault lit le keyvault depuis la première copie valide (primaire, backup,
|
||||
// db), puis RÉPARE silencieusement les copies manquantes/corrompues à partir de
|
||||
// celle qui a marché. Renvoie (nil, nil) si aucun keyvault n'existe encore.
|
||||
func loadVault() (*keyVault, error) {
|
||||
sources := []func() ([]byte, error){
|
||||
func() ([]byte, error) { return os.ReadFile(vaultPathPrimary()) },
|
||||
func() ([]byte, error) { return os.ReadFile(vaultPathBackup()) },
|
||||
func() ([]byte, error) {
|
||||
b := getBytes(bkState, vaultDBKey)
|
||||
if len(b) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return b, nil
|
||||
},
|
||||
}
|
||||
var found *keyVault
|
||||
anyPresent := false
|
||||
healthy := 0 // nb de copies présentes ET valides
|
||||
for _, src := range sources {
|
||||
b, err := src()
|
||||
if err != nil || len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
anyPresent = true
|
||||
var v keyVault
|
||||
if json.Unmarshal(b, &v) == nil && v.V > 0 && len(v.Wraps) > 0 {
|
||||
healthy++
|
||||
if found == nil {
|
||||
vv := v
|
||||
found = &vv
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
if anyPresent {
|
||||
return nil, errors.New("keyvault présent mais illisible dans toutes les copies")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
// Ré-harmonise UNIQUEMENT si une des trois copies manque ou est corrompue :
|
||||
// sinon loadVault (appelé à chaque sondage de santé) réécrirait le disque en
|
||||
// boucle pour rien.
|
||||
if healthy < 3 {
|
||||
_ = saveVault(found)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// vaultExists indique si un keyvault est présent (mémoire chiffrée configurée).
|
||||
func vaultExists() bool {
|
||||
v, _ := loadVault()
|
||||
return v != nil
|
||||
}
|
||||
|
||||
// stripServerOpenableWraps retire du coffre tout wrap ouvrable par un secret que
|
||||
// le SERVEUR détient (la clé de pilotage web, jadis ajoutée comme wrap
|
||||
// « clé d'accès »). Objectif : le serveur ne doit JAMAIS pouvoir déchiffrer seul.
|
||||
// La clé de chiffrement ne vit que côté client (localStorage). Idempotent ;
|
||||
// refuse de vider le coffre (removeWrap garde toujours au moins un wrap).
|
||||
func stripServerOpenableWraps() {
|
||||
v, _ := loadVault()
|
||||
if v == nil || !v.hasKind(wrapE2ERoot) {
|
||||
return
|
||||
}
|
||||
if n, err := v.removeWrap(wrapE2ERoot, ""); err == nil && n > 0 {
|
||||
_ = saveVault(v)
|
||||
}
|
||||
}
|
||||
|
||||
// removeVault efface les trois copies du keyvault (après un déchiffrement complet
|
||||
// où il n'a plus lieu d'être).
|
||||
func removeVault() {
|
||||
os.Remove(vaultPathPrimary())
|
||||
os.Remove(vaultPathBackup())
|
||||
putBytes(bkState, vaultDBKey, nil)
|
||||
}
|
||||
|
||||
// --- Clé de récupération ------------------------------------------------------
|
||||
|
||||
// recoveryEnc : base32 Crockford (32 caractères, sans les ambigus I/L/O/U),
|
||||
// pour une saisie humaine sans confusion.
|
||||
var recoveryEnc = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
|
||||
|
||||
// newRecoveryKey génère une clé de récupération lisible : 20 octets aléatoires,
|
||||
// encodés en groupes de 4 (ex. ABCD-EFGH-JKMN-...). C'est un SECRET fort, à
|
||||
// noter hors ligne ; il rouvre la mémoire même si le mot de passe est perdu.
|
||||
func newRecoveryKey() (string, error) {
|
||||
raw, err := randBytes(20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := recoveryEnc.EncodeToString(raw)
|
||||
var b strings.Builder
|
||||
for i, r := range s {
|
||||
if i > 0 && i%4 == 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// normalizeRecovery met une clé de récupération saisie sous forme canonique
|
||||
// (majuscules, sans séparateurs) pour la dérivation de KEK.
|
||||
func normalizeRecovery(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.NewReplacer("-", "", " ", "", "\t", "").Replace(s)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func trackerStoreKey(proj, slug string) string { return proj + "/" + slug }
|
||||
// trackerLoad lit un tracker du projet actif par son slug. false si absent.
|
||||
func trackerLoad(slug string) (*Tracker, bool) {
|
||||
var s Tracker
|
||||
if !getJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
|
||||
if !getStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
|
||||
return nil, false
|
||||
}
|
||||
return &s, true
|
||||
@@ -55,7 +55,7 @@ func trackerLoad(slug string) (*Tracker, bool) {
|
||||
|
||||
func trackerSave(slug string, s *Tracker) error {
|
||||
sort.SliceStable(s.Events, func(i, j int) bool { return s.Events[i].TS < s.Events[j].TS })
|
||||
return putJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
|
||||
return putStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
|
||||
}
|
||||
|
||||
// trackerKeysForProject renvoie les clés brutes du bucket appartenant à un projet.
|
||||
|
||||
@@ -2584,6 +2584,22 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Chiffrement au repos. La clé est celle de pilotage de CET appareil
|
||||
(localStorage) : le serveur n'en a que l'empreinte, il ne peut pas
|
||||
ouvrir le coffre seul. Couvre les pages, les discussions, les blocs
|
||||
archivés au compactage et les trackers. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="mem-enc-toggle" onchange="toggleMemEncrypt()"><span class="slider"></span></span> chiffrer la mémoire sur le disque<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Chiffre en <b>AES-256-GCM</b> les pages mémoire, les discussions, les blocs archivés et les trackers sous <code>/data</code>. La clé de données est enfermée dans un coffre (Argon2id) ouvert par la <b>clé de pilotage de cet appareil</b> ou par une <b>clé de récupération</b> donnée une seule fois à l'activation. Un <b>snapshot</b> est pris avant chaque bascule, et rien n'est supprimé avant relecture vérifiée.</span></span></label>
|
||||
<div class="rowbody"><div id="mem-enc-status" class="muted" style="font-size:11px"></div></div>
|
||||
<!-- Sauvegarde : le MÊME paquet chiffré que l'amont envoie à son relais,
|
||||
mais en fichier local — rien ne quitte la machine sans qu'on le
|
||||
demande. Nécessite le chiffrement actif : c'est sa clé qui scelle. -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Sauvegarde chiffrée</span>
|
||||
<span class="pe-row-c">
|
||||
<button onclick="backupExport()">exporter</button>
|
||||
<button onclick="backupImport()">importer</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
|
||||
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
|
||||
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
|
||||
@@ -4328,6 +4344,7 @@ async function loadAgent(){
|
||||
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
|
||||
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
|
||||
renderMemList();
|
||||
loadMemEnc();
|
||||
}
|
||||
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
@@ -4385,6 +4402,120 @@ function renderMemList(){
|
||||
}
|
||||
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
|
||||
const loadMem = loadAgent;
|
||||
|
||||
// --- Chiffrement de la mémoire (pages, discussions, blocs archivés, trackers) -
|
||||
//
|
||||
// La clé de chiffrement EST la clé de pilotage (localStorage 'loki.key'), qui ne
|
||||
// vit que sur cet appareil — le serveur n'en garde qu'une empreinte, il ne peut
|
||||
// donc pas ouvrir le coffre seul. Une seule clé pour l'accès ET le chiffrement :
|
||||
// avoir l'interface sous les yeux suffit à déverrouiller, sans second secret à
|
||||
// retenir.
|
||||
let memUnlockDone=false; // tentative de déverrouillage déjà faite ce chargement
|
||||
async function loadMemEnc(){
|
||||
let h; try{ h=await jget('/api/mem/health'); }catch(e){ return; }
|
||||
const tog=document.getElementById('mem-enc-toggle'); if(!tog) return;
|
||||
// La case n'est cochée QUE si TOUT est réellement chiffré (h.fully). Un
|
||||
// chiffrement partiel, raté ou verrouillé-incomplet la laisse décochée : si
|
||||
// elle est cochée, c'est que ça l'est vraiment.
|
||||
tog.checked=!!h.fully;
|
||||
const st=document.getElementById('mem-enc-status');
|
||||
if(st){
|
||||
if(!h.encrypted) st.textContent='pages, discussions, blocs archivés et trackers sont en clair sous /data.';
|
||||
else if(h.locked) st.textContent='chiffré — verrouillé : rien n\'est lisible tant que la clé n\'est pas fournie.';
|
||||
else st.textContent='chiffré et déverrouillé · '+(h.pages|0)+' page(s)'+(h.mixed?' ⚠ certaines pages sont encore en clair':'');
|
||||
}
|
||||
// Résilience : si le drapeau a disparu mais qu'un coffre existe encore
|
||||
// (vault_copies>0), on propose QUAND MÊME le déverrouillage — sinon la
|
||||
// mémoire reste muette et rien ne demande la clé.
|
||||
const flagLost = !h.encrypted && (h.vault_copies|0) > 0;
|
||||
if(((h.encrypted && h.locked) || flagLost) && !memUnlockDone){
|
||||
memUnlockDone=true;
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(apiKey){ const r=await jpost('/api/mem/unlock',{secret:apiKey}); if(r.ok){ loadMem(); return; } }
|
||||
// Boucle : une clé fausse redemande tout de suite (pas besoin de recharger).
|
||||
while(true){
|
||||
const s2=await askPrompt('Saisis la clé de pilotage de cet appareil, ou la clé de récupération notée à l\'activation.',{title:'Mémoire verrouillée',placeholder:'clé de pilotage ou clé de récupération'});
|
||||
if(!s2){ memUnlockDone=false; break; } // annulé : on redemandera au prochain chargement
|
||||
const r=await jpost('/api/mem/unlock',{secret:s2});
|
||||
if(r.ok){ loadMem(); return; }
|
||||
await askAlert('Clé refusée.',{title:'Refusé'});
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Sauvegarde chiffrée en fichier (mémoire + presets + réglages) ----------
|
||||
// Le corps est scellé avec la clé de la mémoire ; la restauration ne demande que
|
||||
// la clé de pilotage (ou la clé de récupération). Aucun relais : c'est un
|
||||
// fichier, que tu ranges où tu veux.
|
||||
async function backupExport(){
|
||||
let r;
|
||||
try{ r = await jfetch('/api/backup/export'); }
|
||||
catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(!r.ok){
|
||||
let msg='échec'; try{ const j=await r.json(); msg=j.error||msg; }catch(_){}
|
||||
await askAlert(msg, {title:'Sauvegarde impossible'});
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
// Le nom vient du serveur (Content-Disposition) ; repli daté côté client.
|
||||
let name = 'loki-backup.lkbk';
|
||||
const cd = r.headers.get('Content-Disposition')||'';
|
||||
const m = /filename="([^"]+)"/.exec(cd);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
toast('sauvegarde téléchargée');
|
||||
}
|
||||
async function backupImport(){
|
||||
if(!await askConfirm("La mémoire, les presets et les réglages de CE serveur seront remplacés par ceux du paquet. Un snapshot de la mémoire actuelle est pris avant.",{title:'Restaurer une sauvegarde ?',okText:'Choisir le fichier',danger:true})) return;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = '.lkbk,application/octet-stream';
|
||||
inp.onchange = async () => {
|
||||
const f = inp.files && inp.files[0];
|
||||
if(!f) return;
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
// btoa par tranches : un spread sur un tableau de plusieurs Mo fait sauter
|
||||
// la pile d'appels.
|
||||
let bin = '';
|
||||
for(let i=0;i<buf.length;i+=0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i+0x8000));
|
||||
const data = btoa(bin);
|
||||
// Boucle : une clé fausse redemande tout de suite.
|
||||
while(true){
|
||||
const secret = await askPrompt("Clé de pilotage de la machine d'origine, ou clé de récupération.",{title:'Restaurer',placeholder:'clé'});
|
||||
if(!secret) return;
|
||||
toast('restauration…');
|
||||
let r; try{ r = await jpost('/api/backup/import',{data, secret}); }catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(r && r.ok){ await askAlert('Sauvegarde restaurée.',{title:'Terminé'}); loadMem(); return; }
|
||||
await askAlert((r&&r.error)||'échec',{title:'Refusé'});
|
||||
}
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
async function toggleMemEncrypt(){
|
||||
const tog=document.getElementById('mem-enc-toggle');
|
||||
if(tog.checked){
|
||||
// Déjà chiffré mais incomplet (verrouillé, ou discussions à migrer) : on ne
|
||||
// relance PAS un chiffrement, on complète en déverrouillant.
|
||||
let cur; try{ cur=await jget('/api/mem/health'); }catch(e){}
|
||||
if(cur && cur.encrypted){ memUnlockDone=false; await loadMemEnc(); return; }
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(!apiKey){ await askAlert("Le chiffrement utilise la clé de pilotage de cet appareil, et il n'y en a pas. Règle-la d'abord (loki set-web-key), puis reviens.",{title:'Clé de pilotage requise'}); tog.checked=false; return; }
|
||||
const ok=await askConfirm("Les pages mémoire, les discussions, les blocs archivés et les trackers seront chiffrés sur le disque. La clé de cet appareil les ouvre ; une clé de récupération te sera donnée UNE fois — note-la, elle est ta seule porte de secours.",{title:'Chiffrer la mémoire ?',okText:'Chiffrer'});
|
||||
if(!ok){ tog.checked=false; return; }
|
||||
let r; try{ r=await jpost('/api/mem/encrypt',{password:apiKey}); }catch(e){ await askAlert('échec : '+e); tog.checked=false; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=false; loadMemEnc(); return; }
|
||||
memUnlockDone=true;
|
||||
await askAlert("Note cette clé de récupération, elle ne sera plus jamais affichée :\n\n"+r.recovery,{title:'Clé de récupération'});
|
||||
toast('chiffrement activé'); loadMem();
|
||||
} else {
|
||||
const ok=await askConfirm('Tout sera réécrit EN CLAIR sur le disque.',{title:'Déchiffrer la mémoire ?',okText:'Déchiffrer',danger:true});
|
||||
if(!ok){ tog.checked=true; return; }
|
||||
let r; try{ r=await jpost('/api/mem/decrypt',{}); }catch(e){ await askAlert('échec : '+e); tog.checked=true; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=true; loadMemEnc(); return; }
|
||||
toast('chiffrement désactivé'); loadMem();
|
||||
}
|
||||
}
|
||||
async function toggleAgent(){
|
||||
const on=document.getElementById('agent-toggle').checked;
|
||||
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
|
||||
|
||||
@@ -383,6 +383,22 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Chiffrement au repos. La clé est celle de pilotage de CET appareil
|
||||
(localStorage) : le serveur n'en a que l'empreinte, il ne peut pas
|
||||
ouvrir le coffre seul. Couvre les pages, les discussions, les blocs
|
||||
archivés au compactage et les trackers. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="mem-enc-toggle" onchange="toggleMemEncrypt()"><span class="slider"></span></span> chiffrer la mémoire sur le disque<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Chiffre en <b>AES-256-GCM</b> les pages mémoire, les discussions, les blocs archivés et les trackers sous <code>/data</code>. La clé de données est enfermée dans un coffre (Argon2id) ouvert par la <b>clé de pilotage de cet appareil</b> ou par une <b>clé de récupération</b> donnée une seule fois à l'activation. Un <b>snapshot</b> est pris avant chaque bascule, et rien n'est supprimé avant relecture vérifiée.</span></span></label>
|
||||
<div class="rowbody"><div id="mem-enc-status" class="muted" style="font-size:11px"></div></div>
|
||||
<!-- Sauvegarde : le MÊME paquet chiffré que l'amont envoie à son relais,
|
||||
mais en fichier local — rien ne quitte la machine sans qu'on le
|
||||
demande. Nécessite le chiffrement actif : c'est sa clé qui scelle. -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Sauvegarde chiffrée</span>
|
||||
<span class="pe-row-c">
|
||||
<button onclick="backupExport()">exporter</button>
|
||||
<button onclick="backupImport()">importer</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
|
||||
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
|
||||
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
|
||||
|
||||
@@ -128,6 +128,7 @@ async function loadAgent(){
|
||||
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
|
||||
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
|
||||
renderMemList();
|
||||
loadMemEnc();
|
||||
}
|
||||
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
@@ -185,6 +186,120 @@ function renderMemList(){
|
||||
}
|
||||
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
|
||||
const loadMem = loadAgent;
|
||||
|
||||
// --- Chiffrement de la mémoire (pages, discussions, blocs archivés, trackers) -
|
||||
//
|
||||
// La clé de chiffrement EST la clé de pilotage (localStorage 'loki.key'), qui ne
|
||||
// vit que sur cet appareil — le serveur n'en garde qu'une empreinte, il ne peut
|
||||
// donc pas ouvrir le coffre seul. Une seule clé pour l'accès ET le chiffrement :
|
||||
// avoir l'interface sous les yeux suffit à déverrouiller, sans second secret à
|
||||
// retenir.
|
||||
let memUnlockDone=false; // tentative de déverrouillage déjà faite ce chargement
|
||||
async function loadMemEnc(){
|
||||
let h; try{ h=await jget('/api/mem/health'); }catch(e){ return; }
|
||||
const tog=document.getElementById('mem-enc-toggle'); if(!tog) return;
|
||||
// La case n'est cochée QUE si TOUT est réellement chiffré (h.fully). Un
|
||||
// chiffrement partiel, raté ou verrouillé-incomplet la laisse décochée : si
|
||||
// elle est cochée, c'est que ça l'est vraiment.
|
||||
tog.checked=!!h.fully;
|
||||
const st=document.getElementById('mem-enc-status');
|
||||
if(st){
|
||||
if(!h.encrypted) st.textContent='pages, discussions, blocs archivés et trackers sont en clair sous /data.';
|
||||
else if(h.locked) st.textContent='chiffré — verrouillé : rien n\'est lisible tant que la clé n\'est pas fournie.';
|
||||
else st.textContent='chiffré et déverrouillé · '+(h.pages|0)+' page(s)'+(h.mixed?' ⚠ certaines pages sont encore en clair':'');
|
||||
}
|
||||
// Résilience : si le drapeau a disparu mais qu'un coffre existe encore
|
||||
// (vault_copies>0), on propose QUAND MÊME le déverrouillage — sinon la
|
||||
// mémoire reste muette et rien ne demande la clé.
|
||||
const flagLost = !h.encrypted && (h.vault_copies|0) > 0;
|
||||
if(((h.encrypted && h.locked) || flagLost) && !memUnlockDone){
|
||||
memUnlockDone=true;
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(apiKey){ const r=await jpost('/api/mem/unlock',{secret:apiKey}); if(r.ok){ loadMem(); return; } }
|
||||
// Boucle : une clé fausse redemande tout de suite (pas besoin de recharger).
|
||||
while(true){
|
||||
const s2=await askPrompt('Saisis la clé de pilotage de cet appareil, ou la clé de récupération notée à l\'activation.',{title:'Mémoire verrouillée',placeholder:'clé de pilotage ou clé de récupération'});
|
||||
if(!s2){ memUnlockDone=false; break; } // annulé : on redemandera au prochain chargement
|
||||
const r=await jpost('/api/mem/unlock',{secret:s2});
|
||||
if(r.ok){ loadMem(); return; }
|
||||
await askAlert('Clé refusée.',{title:'Refusé'});
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Sauvegarde chiffrée en fichier (mémoire + presets + réglages) ----------
|
||||
// Le corps est scellé avec la clé de la mémoire ; la restauration ne demande que
|
||||
// la clé de pilotage (ou la clé de récupération). Aucun relais : c'est un
|
||||
// fichier, que tu ranges où tu veux.
|
||||
async function backupExport(){
|
||||
let r;
|
||||
try{ r = await jfetch('/api/backup/export'); }
|
||||
catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(!r.ok){
|
||||
let msg='échec'; try{ const j=await r.json(); msg=j.error||msg; }catch(_){}
|
||||
await askAlert(msg, {title:'Sauvegarde impossible'});
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
// Le nom vient du serveur (Content-Disposition) ; repli daté côté client.
|
||||
let name = 'loki-backup.lkbk';
|
||||
const cd = r.headers.get('Content-Disposition')||'';
|
||||
const m = /filename="([^"]+)"/.exec(cd);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
toast('sauvegarde téléchargée');
|
||||
}
|
||||
async function backupImport(){
|
||||
if(!await askConfirm("La mémoire, les presets et les réglages de CE serveur seront remplacés par ceux du paquet. Un snapshot de la mémoire actuelle est pris avant.",{title:'Restaurer une sauvegarde ?',okText:'Choisir le fichier',danger:true})) return;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = '.lkbk,application/octet-stream';
|
||||
inp.onchange = async () => {
|
||||
const f = inp.files && inp.files[0];
|
||||
if(!f) return;
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
// btoa par tranches : un spread sur un tableau de plusieurs Mo fait sauter
|
||||
// la pile d'appels.
|
||||
let bin = '';
|
||||
for(let i=0;i<buf.length;i+=0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i+0x8000));
|
||||
const data = btoa(bin);
|
||||
// Boucle : une clé fausse redemande tout de suite.
|
||||
while(true){
|
||||
const secret = await askPrompt("Clé de pilotage de la machine d'origine, ou clé de récupération.",{title:'Restaurer',placeholder:'clé'});
|
||||
if(!secret) return;
|
||||
toast('restauration…');
|
||||
let r; try{ r = await jpost('/api/backup/import',{data, secret}); }catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(r && r.ok){ await askAlert('Sauvegarde restaurée.',{title:'Terminé'}); loadMem(); return; }
|
||||
await askAlert((r&&r.error)||'échec',{title:'Refusé'});
|
||||
}
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
async function toggleMemEncrypt(){
|
||||
const tog=document.getElementById('mem-enc-toggle');
|
||||
if(tog.checked){
|
||||
// Déjà chiffré mais incomplet (verrouillé, ou discussions à migrer) : on ne
|
||||
// relance PAS un chiffrement, on complète en déverrouillant.
|
||||
let cur; try{ cur=await jget('/api/mem/health'); }catch(e){}
|
||||
if(cur && cur.encrypted){ memUnlockDone=false; await loadMemEnc(); return; }
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(!apiKey){ await askAlert("Le chiffrement utilise la clé de pilotage de cet appareil, et il n'y en a pas. Règle-la d'abord (loki set-web-key), puis reviens.",{title:'Clé de pilotage requise'}); tog.checked=false; return; }
|
||||
const ok=await askConfirm("Les pages mémoire, les discussions, les blocs archivés et les trackers seront chiffrés sur le disque. La clé de cet appareil les ouvre ; une clé de récupération te sera donnée UNE fois — note-la, elle est ta seule porte de secours.",{title:'Chiffrer la mémoire ?',okText:'Chiffrer'});
|
||||
if(!ok){ tog.checked=false; return; }
|
||||
let r; try{ r=await jpost('/api/mem/encrypt',{password:apiKey}); }catch(e){ await askAlert('échec : '+e); tog.checked=false; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=false; loadMemEnc(); return; }
|
||||
memUnlockDone=true;
|
||||
await askAlert("Note cette clé de récupération, elle ne sera plus jamais affichée :\n\n"+r.recovery,{title:'Clé de récupération'});
|
||||
toast('chiffrement activé'); loadMem();
|
||||
} else {
|
||||
const ok=await askConfirm('Tout sera réécrit EN CLAIR sur le disque.',{title:'Déchiffrer la mémoire ?',okText:'Déchiffrer',danger:true});
|
||||
if(!ok){ tog.checked=true; return; }
|
||||
let r; try{ r=await jpost('/api/mem/decrypt',{}); }catch(e){ await askAlert('échec : '+e); tog.checked=true; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=true; loadMemEnc(); return; }
|
||||
toast('chiffrement désactivé'); loadMem();
|
||||
}
|
||||
}
|
||||
async function toggleAgent(){
|
||||
const on=document.getElementById('agent-toggle').checked;
|
||||
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -698,6 +700,175 @@ func localIP() string {
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// --- Chiffrement de la mémoire (mem_crypto/mem_vault/mem_migrate) -----------
|
||||
|
||||
// handleMemHealth : état du chiffrement (actif ? verrouillé ? tout chiffré ?),
|
||||
// nombre de pages et snapshots locaux.
|
||||
func handleMemHealth(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, memHealth())
|
||||
}
|
||||
|
||||
// handleMemEncrypt active le chiffrement. Le corps porte le mot de passe
|
||||
// mémoire. La réponse contient la CLÉ DE RÉCUPÉRATION, à afficher UNE fois :
|
||||
// elle n'est jamais reconsultable ensuite.
|
||||
func handleMemEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
rec, err := EnableMemEncryption(req.Password)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "recovery": rec, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemDecrypt remet la mémoire en clair (exige d'être déverrouillé).
|
||||
func handleMemDecrypt(w http.ResponseWriter, r *http.Request) {
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemUnlock déverrouille la mémoire : accepte le mot de passe OU la clé
|
||||
// de récupération (on tente les deux formes). Charge la DEK en RAM.
|
||||
func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if strings.TrimSpace(req.Secret) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "secret vide"})
|
||||
return
|
||||
}
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun coffre (mémoire non chiffrée ?)"})
|
||||
return
|
||||
}
|
||||
dek, kind, err := v.unlockWith(req.Secret)
|
||||
if err != nil {
|
||||
// La clé de récupération se saisit avec tirets/espaces : on retente normalisée.
|
||||
if d2, k2, e2 := v.unlockWith(normalizeRecovery(req.Secret)); e2 == nil {
|
||||
dek, kind, err = d2, k2, e2
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(w, 401, map[string]any{"ok": false, "error": "secret incorrect"})
|
||||
return
|
||||
}
|
||||
setMemDEK(dek)
|
||||
resumeMemMigration() // si une migration attendait le déverrouillage
|
||||
reloadEncryptedStores() // recharge la discussion chiffrée en RAM
|
||||
// Migration douce : chiffre ce qui serait resté en clair (discussions,
|
||||
// blocs archivés, trackers). Idempotent — n'encode que ce qui ne l'est pas.
|
||||
if memEncActive() && memUnlocked() {
|
||||
_ = reencryptChatStores()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemLock purge la DEK de la RAM (reverrouille sans redémarrer).
|
||||
func handleMemLock(w http.ResponseWriter, r *http.Request) {
|
||||
clearMemDEK()
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleMemSnapshots liste les snapshots locaux de la mémoire (GET) ou en
|
||||
// restaure un (POST {id}). Un snapshot est pris avant chaque opération qui
|
||||
// touche à tout (chiffrement, déchiffrement) : c'est le filet.
|
||||
func handleMemSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := restoreSnapshot(req.ID); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "snapshots": listSnapshots()})
|
||||
}
|
||||
|
||||
// --- Sauvegarde chiffrée, en FICHIER (pas de relais) ------------------------
|
||||
//
|
||||
// L'amont sauvegarde vers son relais (ajean.link). Ici, le même paquet chiffré
|
||||
// se télécharge et se réimporte À LA MAIN : rien ne quitte la machine sans que
|
||||
// tu l'aies demandé, et un conteneur se remonte ailleurs avec un seul fichier.
|
||||
// Le corps est chiffré avec la DEK de la mémoire ; l'entête porte le coffre, si
|
||||
// bien que la restauration n'a besoin que de la clé de pilotage (ou de la clé
|
||||
// de récupération) — aucun état de l'ancienne machine.
|
||||
|
||||
// handleBackupExport (GET) télécharge le paquet chiffré {mémoire, presets,
|
||||
// réglages}. Exige une mémoire chiffrée ET déverrouillée : sans DEK, il n'y a
|
||||
// pas de quoi sceller le paquet, et l'envoyer en clair serait un piège.
|
||||
func handleBackupExport(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun coffre : active d'abord le chiffrement de la mémoire"})
|
||||
return
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "mémoire verrouillée"})
|
||||
return
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := "loki-backup-" + time.Now().Format("20060102-150405") + ".lkbk"
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(blob)))
|
||||
_, _ = w.Write(blob)
|
||||
}
|
||||
|
||||
// handleBackupImport (POST {data: base64, secret}) restaure un paquet exporté.
|
||||
// Un snapshot de la mémoire est pris AVANT d'écrire quoi que ce soit.
|
||||
func handleBackupImport(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Data string `json:"data"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// "data:application/octet-stream;base64,…" : on ne garde que la charge utile.
|
||||
data := req.Data
|
||||
if i := strings.Index(data, ","); i >= 0 && strings.HasPrefix(data, "data:") {
|
||||
data = data[i+1:]
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data))
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier illisible (base64 attendu)"})
|
||||
return
|
||||
}
|
||||
tarData, err := openBackupBlob(blob, req.Secret)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := restoreBundleTar(tarData); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
|
||||
}
|
||||
|
||||
// handleComputer pilote le pilotage de navigateur (outils browser_*).
|
||||
//
|
||||
// GET → {enabled, browser, browser_ok, vision}
|
||||
|
||||
@@ -122,6 +122,10 @@ func newWebMux() *http.ServeMux {
|
||||
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
|
||||
// plus rien du tout.
|
||||
lcRestoreOnce.Do(lcRestore)
|
||||
// Chiffrement de la mémoire : une migration interrompue (coupure de courant
|
||||
// en plein chiffrement) reprend ici, si la clé est disponible. Sans clé, elle
|
||||
// attend le déverrouillage (voir handleMemUnlock). Idempotent.
|
||||
resumeMemMigration()
|
||||
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
|
||||
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
|
||||
StartTaskScheduler()
|
||||
@@ -262,7 +266,15 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
|
||||
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/computer", handleComputer) // pilotage de navigateur (outils browser_*)
|
||||
api("/api/computer", handleComputer) // pilotage de navigateur (outils browser_*)
|
||||
api("/api/mem/health", handleMemHealth) // état chiffrement/verrou/pages/snapshots
|
||||
api("/api/mem/encrypt", handleMemEncrypt) // active le chiffrement (renvoie la clé de récupération)
|
||||
api("/api/mem/decrypt", handleMemDecrypt) // remet la mémoire en clair
|
||||
api("/api/mem/unlock", handleMemUnlock) // déverrouille (mot de passe ou clé de récupération)
|
||||
api("/api/mem/lock", handleMemLock) // reverrouille (purge la DEK de la RAM)
|
||||
api("/api/mem/snapshots", handleMemSnapshots) // liste + restauration des snapshots locaux
|
||||
api("/api/backup/export", handleBackupExport) // télécharge le paquet chiffré (mémoire+presets+réglages)
|
||||
api("/api/backup/import", handleBackupImport) // restaure un paquet exporté
|
||||
api("/api/mcp", handleMCP)
|
||||
api("/api/mcp/catalog", handleMCPCatalog)
|
||||
api("/api/mcp/save", handleMCPSave)
|
||||
|
||||
Reference in new issue
Block a user