mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Reprise d'AJEAN (mem_crypto/mem_vault/mem_store/mem_io/mem_migrate/ mem_snapshots/mem_health/mem_fsutil + backup_bundle), adaptée à Loki. Chiffrement à enveloppe : une DEK tirée une fois chiffre les données en AES-256-GCM ; elle est enfermée dans un coffre par une KEK dérivée en Argon2id. Ce qui ouvre le coffre : la clé de pilotage de l'appareil (le serveur n'en a que l'empreinte, il ne peut pas ouvrir seul) ou la clé de récupération donnée une fois. La DEK ne vit qu'en RAM. Périmètre chiffré, choisi pour Loki : les pages mémoire, les discussions (journal ET index, qui porte les titres), les blocs archivés au compactage — du verbatim de conversation — et les trackers. Pas les buckets de réglages : le coffre lui-même y vit, les chiffrer serait une boucle. Verrouillé, rien n'est écrasé : putStoreBytes REFUSE d'écrire du clair par-dessus du chiffré, la liste des discussions se lit vide et les pages s'affichent « 🔒 chiffré ». Le déverrouillage recharge la discussion et rattrape ce qui serait resté en clair. Une migration interrompue reprend au démarrage, un snapshot est pris avant chaque bascule, et aucune donnée n'est supprimée avant relecture vérifiée de son remplaçant. Piège corrigé au passage : chiffrer À L'INTÉRIEUR d'une transaction bbolt se bloquait sur le verrou de la base (memEncActive relit la config, donc la base). L'état du chiffrement est désormais résolu AVANT la transaction (memEncoderNow), qui ne reçoit plus qu'un encodeur pur. Sauvegarde : le paquet chiffré {mémoire, presets, réglages} s'exporte et s'importe en FICHIER (/api/backup/export, /api/backup/import). La sauvegarde vers le relais ajean.link n'est pas reprise — c'est le service de l'auteur amont ; ici le fichier reste chez soi. Le dossier mémoire n'était déjà plus joignable qu'aux outils mem_* : c'était la condition de ce chiffrement (un `cat memory/…` aurait rendu du binaire). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
376 lines
11 KiB
Go
376 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
|
|
}
|
|
|
|
// 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
|
|
}
|