mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Index inversé dans le bucket chatsearch (chat_search.go) : une fiche légère par discussion (« d| » : version, date d'indexation, aperçu), sa liste de mots (« t| ») et, par mot, la liste des discussions (« p| »). Mots repliés sans accents, correspondance par début de mot, titre compté double, comme chez l'amont. Chiffré quand la mémoire l'est (encryptedBuckets) ; verrouillée, la recherche le dit (state=locked). Adapté au modèle de Loki : toutes les discussions vivent dans bkChat/conv:<id>. L'index suit chaque écriture de l'écrivain différé (chat_persist.go, avant que le lot soit marqué traité : une suppression passe toujours après) et ne touche que les mots ajoutés ou retirés. Il suit aussi les écritures de la façade Rust, qui passent par /internal/v1/store : le Go reste le seul à tenir l'index, la route /api/conversations/search est servie ici et relayée par la façade comme toute route qu'elle ne connaît pas (test de garde des deux côtés). Pas de second écrivain, donc pas de test de parité à tenir. Rattrapage en fond au démarrage, au déverrouillage et quand une recherche trouve l'index incomplet : discussions absentes ou plus anciennes que leur dernière écriture. La discussion ouverte est cherchée dans son journal en RAM quand ce process la tient. L'extrait est pris autour du mot trouvé, relu dans le corps des seuls résultats de la page. Tests repris d'AJEAN (chat_search_test.go), adaptés, plus les écritures de la façade, l'extrait en contexte, la mise à jour incrémentale et l'index périmé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
379 lines
11 KiB
Go
379 lines
11 KiB
Go
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
|
|
}
|
|
|
|
// memLocked : mémoire chiffrée MAIS verrouillée (DEK absente).
|
|
func memLocked() bool { return memEncActive() && !memUnlocked() }
|
|
|
|
// 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
|
|
}
|