diff --git a/NOTICE.md b/NOTICE.md index 0a1f0fa..7ef50c1 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -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 diff --git a/README.md b/README.md index 45419bd..4c87ace 100644 --- a/README.md +++ b/README.md @@ -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//` | 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 diff --git a/internal/loki/backup_bundle.go b/internal/loki/backup_bundle.go new file mode 100644 index 0000000..9bdd5b8 --- /dev/null +++ b/internal/loki/backup_bundle.go @@ -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//...). + 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//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 +} diff --git a/internal/loki/backup_bundle_test.go b/internal/loki/backup_bundle_test.go new file mode 100644 index 0000000..f0ce34c --- /dev/null +++ b/internal/loki/backup_bundle_test.go @@ -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") + } +} diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index bccff02..a0ae96d 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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) } diff --git a/internal/loki/chat_convfiles.go b/internal/loki/chat_convfiles.go index a060d05..1daa701 100644 --- a/internal/loki/chat_convfiles.go +++ b/internal/loki/chat_convfiles.go @@ -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 } diff --git a/internal/loki/chat_memory.go b/internal/loki/chat_memory.go index 8d14760..61ac290 100644 --- a/internal/loki/chat_memory.go +++ b/internal/loki/chat_memory.go @@ -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, diff --git a/internal/loki/chat_recall.go b/internal/loki/chat_recall.go index efa07d9..e439769 100644 --- a/internal/loki/chat_recall.go +++ b/internal/loki/chat_recall.go @@ -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) diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index 73533f6..fa2e061 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -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 } diff --git a/internal/loki/mem_crypto.go b/internal/loki/mem_crypto.go new file mode 100644 index 0000000..fb38a40 --- /dev/null +++ b/internal/loki/mem_crypto.go @@ -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 +} diff --git a/internal/loki/mem_crypto_test.go b/internal/loki/mem_crypto_test.go new file mode 100644 index 0000000..487a52d --- /dev/null +++ b/internal/loki/mem_crypto_test.go @@ -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") + } +} diff --git a/internal/loki/mem_fsutil.go b/internal/loki/mem_fsutil.go new file mode 100644 index 0000000..2f0e132 --- /dev/null +++ b/internal/loki/mem_fsutil.go @@ -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 +} diff --git a/internal/loki/mem_health.go b/internal/loki/mem_health.go new file mode 100644 index 0000000..0db9e95 --- /dev/null +++ b/internal/loki/mem_health.go @@ -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 +} diff --git a/internal/loki/mem_io.go b/internal/loki/mem_io.go new file mode 100644 index 0000000..31a7245 --- /dev/null +++ b/internal/loki/mem_io.go @@ -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) +} diff --git a/internal/loki/mem_migrate.go b/internal/loki/mem_migrate.go new file mode 100644 index 0000000..56e3244 --- /dev/null +++ b/internal/loki/mem_migrate.go @@ -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//*.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//*.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() + } + } + } +} diff --git a/internal/loki/mem_migrate_test.go b/internal/loki/mem_migrate_test.go new file mode 100644 index 0000000..2316836 --- /dev/null +++ b/internal/loki/mem_migrate_test.go @@ -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) + } + } +} diff --git a/internal/loki/mem_snapshots.go b/internal/loki/mem_snapshots.go new file mode 100644 index 0000000..a80a1d1 --- /dev/null +++ b/internal/loki/mem_snapshots.go @@ -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- + 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{} +} diff --git a/internal/loki/mem_store.go b/internal/loki/mem_store.go new file mode 100644 index 0000000..f810a15 --- /dev/null +++ b/internal/loki/mem_store.go @@ -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 +} diff --git a/internal/loki/mem_store_test.go b/internal/loki/mem_store_test.go new file mode 100644 index 0000000..d1f314e --- /dev/null +++ b/internal/loki/mem_store_test.go @@ -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é") + } +} diff --git a/internal/loki/mem_vault.go b/internal/loki/mem_vault.go new file mode 100644 index 0000000..b318300 --- /dev/null +++ b/internal/loki/mem_vault.go @@ -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 +} diff --git a/internal/loki/mem_vault_test.go b/internal/loki/mem_vault_test.go new file mode 100644 index 0000000..6435159 --- /dev/null +++ b/internal/loki/mem_vault_test.go @@ -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")) + } +} diff --git a/internal/loki/tracker.go b/internal/loki/tracker.go index d7af3f6..fd9d849 100644 --- a/internal/loki/tracker.go +++ b/internal/loki/tracker.go @@ -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. diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 66d06b7..f8333bb 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2584,6 +2584,22 @@ html[data-files="1"] #files-btn{color:var(--accent)} + + +
+ +
+ Sauvegarde chiffrée + + + + +
Voir la mémoire @@ -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;idésactivée + + +
+ +
+ Sauvegarde chiffrée + + + + +
Voir la mémoire diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index 7a74f50..ec96aaa 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -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= 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} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index d0e5f19..1bf4e9f 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -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)