feat: implement end-to-end encryption (E2EE) support for agents with configuration and documentation updates

This commit is contained in:
2026-03-05 00:06:32 +00:00
parent 1e5103eb70
commit 54fe479792
11 changed files with 227 additions and 21 deletions
+19
View File
@@ -86,6 +86,25 @@ matrix:
- Token: `MATRIX_TOKEN_<ID_UPPER>` donde ID se convierte a mayúsculas y guiones a underscores - Token: `MATRIX_TOKEN_<ID_UPPER>` donde ID se convierte a mayúsculas y guiones a underscores
- Ejemplo: `asistente-2``MATRIX_TOKEN_ASISTENTE2` - Ejemplo: `asistente-2``MATRIX_TOKEN_ASISTENTE2`
- Password: `MATRIX_PASSWORD_<ID_UPPER>` con la misma convención - Password: `MATRIX_PASSWORD_<ID_UPPER>` con la misma convención
- Pickle key E2EE: `PICKLE_KEY_<ID_UPPER>` — clave fija hex de 32 bytes
**Sección encryption en config.yaml:**
```yaml
encryption:
enabled: true
store_path: "./agents/<agent-id>/data/crypto/" # SIEMPRE por agente, nunca compartida
pickle_key_env: PICKLE_KEY_<ID_UPPER> # env var con clave hex
trust_mode: tofu
```
**Al crear un nuevo agente con E2EE:**
1. Generar pickle key: `openssl rand -hex 32`
2. Añadir a `.env`: `PICKLE_KEY_<ID_UPPER>=<hex>`
3. Añadir a `.env.example`: `PICKLE_KEY_<ID_UPPER>=`
4. Usar `store_path` propio del agente (no compartir entre agentes)
5. Tras arrancar, verificar cross-signing: `go run -tags goolm ./cmd/verify ...`
Ver `docs/e2ee.md` para documentación completa de E2EE.
### 3. `agents/<agent-id>/prompts/system.md` — System prompt ### 3. `agents/<agent-id>/prompts/system.md` — System prompt
+8
View File
@@ -13,8 +13,16 @@ MATRIX_ADMIN_TOKEN=syt_...
# Tokens de cada bot — generados por cmd/register # Tokens de cada bot — generados por cmd/register
MATRIX_TOKEN_ASSISTANT=syt_... MATRIX_TOKEN_ASSISTANT=syt_...
MATRIX_TOKEN_ASISTENTE2=syt_...
MATRIX_TOKEN_DEVOPS=syt_... MATRIX_TOKEN_DEVOPS=syt_...
# ── E2EE pickle keys (openssl rand -hex 32) ─────────────────
# Clave fija por agente para cifrar material crypto en SQLite.
# Si no se define, se usa sha256(access_token) como fallback.
PICKLE_KEY_ASSISTANT_BOT=
PICKLE_KEY_ASISTENTE_2=
PICKLE_KEY_DEVOPS_BOT=
# ── LLM providers ──────────────────────────────────────────── # ── LLM providers ────────────────────────────────────────────
OPENAI_API_KEY=sk-... OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-... # opcional, para cuando añadas el devops-bot con Claude ANTHROPIC_API_KEY=sk-ant-... # opcional, para cuando añadas el devops-bot con Claude
+2 -1
View File
@@ -120,7 +120,8 @@ matrix:
encryption: encryption:
enabled: true enabled: true
store_path: "./data/crypto/" store_path: "./agents/asistente2/data/crypto/"
pickle_key_env: PICKLE_KEY_ASISTENTE_2
trust_mode: tofu trust_mode: tofu
rooms: rooms:
+2 -1
View File
@@ -121,7 +121,8 @@ matrix:
encryption: encryption:
enabled: true enabled: true
store_path: "./data/crypto/" store_path: "./agents/assistant/data/crypto/"
pickle_key_env: PICKLE_KEY_ASSISTANT_BOT
trust_mode: tofu trust_mode: tofu
rooms: rooms:
+2 -1
View File
@@ -135,7 +135,8 @@ matrix:
encryption: encryption:
enabled: false # habilitar cuando E2EE esté configurado enabled: false # habilitar cuando E2EE esté configurado
store_path: "./data/crypto/" store_path: "./agents/devops/data/crypto/"
pickle_key_env: PICKLE_KEY_DEVOPS_BOT
trust_mode: tofu trust_mode: tofu
rooms: rooms:
+10 -1
View File
@@ -6,7 +6,9 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
@@ -49,9 +51,16 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
var cryptoStore io.Closer var cryptoStore io.Closer
if cfg.Matrix.Encryption.Enabled { if cfg.Matrix.Encryption.Enabled {
storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db") storePath := filepath.Join(cfg.Matrix.Encryption.StorePath, "crypto.db")
pickleKey := os.Getenv(cfg.Matrix.Encryption.PickleKeyEnv)
logger.Info("initializing e2ee", "store", storePath) logger.Info("initializing e2ee", "store", storePath)
cryptoStore, err = matrixClient.InitCrypto(context.Background(), storePath, cfg.Agent.ID) cryptoStore, err = matrixClient.InitCrypto(context.Background(), storePath, pickleKey, cfg.Agent.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "not marked as shared") {
logger.Error("crypto store is inconsistent with server — need a fresh device",
"store", storePath,
"fix", "delete crypto.db, login with password to get new token+device, update .env, restart",
)
}
return nil, fmt.Errorf("e2ee init: %w", err) return nil, fmt.Errorf("e2ee init: %w", err)
} }
logger.Info("e2ee ready") logger.Info("e2ee ready")
+2 -1
View File
@@ -151,7 +151,8 @@ matrix:
encryption: encryption:
enabled: false enabled: false
store_path: "./data/crypto/" store_path: "./agents/${ID}/data/crypto/"
pickle_key_env: PICKLE_KEY_$(echo "$ID" | tr '[:lower:]-' '[:upper:]_')
trust_mode: tofu trust_mode: tofu
rooms: rooms:
+8 -6
View File
@@ -111,7 +111,8 @@ matrix:
encryption: encryption:
enabled: true enabled: true
store_path: "./data/crypto/" store_path: "./agents/<agent-id>/data/crypto/"
pickle_key_env: PICKLE_KEY_<AGENT_UPPER>
trust_mode: tofu trust_mode: tofu
``` ```
@@ -221,15 +222,15 @@ go run -tags goolm ./cmd/verify \
3. Las sube al homeserver usando UIA con la password del bot 3. Las sube al homeserver usando UIA con la password del bot
4. Firma el device del bot con la self-signing key 4. Firma el device del bot con la self-signing key
**Después de verificar:** Limpiar el crypto store temporal si se usó uno diferente al del agente.
**Importante:** Si se cambia la password del bot (admin API), el token anterior se invalida. Hay que: **Importante:** Si se cambia la password del bot (admin API), el token anterior se invalida. Hay que:
1. Re-login para obtener nuevo token 1. Re-login para obtener nuevo token
2. Actualizar `MATRIX_TOKEN_<AGENT>` en `.env` 2. Actualizar `MATRIX_TOKEN_<AGENT>` en `.env`
3. Actualizar `device_id` en `config.yaml` 3. Actualizar `device_id` en `config.yaml`
4. Borrar el crypto store viejo (`data/crypto/`) 4. Borrar el crypto store viejo (`agents/<id>/data/crypto/crypto.db`)
5. Re-ejecutar `cmd/verify` 5. Re-ejecutar `cmd/verify`
**Nota:** El pickle key (`PICKLE_KEY_<AGENT>`) NO cambia al rotar el token. Solo se regenera si se pierde. Ver `docs/e2ee.md`.
## Paso 6: Arrancar el agente ## Paso 6: Arrancar el agente
```bash ```bash
@@ -251,7 +252,7 @@ tail -f run/<agent-id>.log
**Logs esperados al arrancar correctamente:** **Logs esperados al arrancar correctamente:**
``` ```
{"level":"INFO","msg":"initializing e2ee","store":"data/crypto/crypto.db"} {"level":"INFO","msg":"initializing e2ee","store":"agents/<id>/data/crypto/crypto.db"}
{"level":"INFO","msg":"e2ee ready"} {"level":"INFO","msg":"e2ee ready"}
{"level":"INFO","msg":"agent starting","id":"<agent-id>","tools":["current_time","matrix_send"]} {"level":"INFO","msg":"agent starting","id":"<agent-id>","tools":["current_time","matrix_send"]}
{"level":"INFO","msg":"starting matrix sync"} {"level":"INFO","msg":"starting matrix sync"}
@@ -305,7 +306,8 @@ tail -f run/<id>.log
|----------|-------|----------| |----------|-------|----------|
| `env var ... is not set` | La regex del `.env` loader no matchea | Verificar que el nombre de la var solo usa `[A-Z0-9_]` | | `env var ... is not set` | La regex del `.env` loader no matchea | Verificar que el nombre de la var solo usa `[A-Z0-9_]` |
| `M_UNKNOWN_TOKEN` | Token invalidado (password cambiada) | Re-login, actualizar `.env` | | `M_UNKNOWN_TOKEN` | Token invalidado (password cambiada) | Re-login, actualizar `.env` |
| `mismatching device ID` | Crypto store con device viejo | Borrar `data/crypto/`, actualizar `device_id` en config | | `mismatching device ID` | Crypto store con device viejo | Borrar `agents/<id>/data/crypto/crypto.db`, actualizar `device_id` en config |
| `olm account not marked as shared` | Crypto store inconsistente | Auto-recovery lo resuelve al reiniciar. Si persiste: borrar crypto.db |
| `"Encrypted by device not verified"` | Falta cross-signing | Ejecutar `cmd/verify` | | `"Encrypted by device not verified"` | Falta cross-signing | Ejecutar `cmd/verify` |
| Bot no responde | Reglas no matchean | Verificar que hay regla catch-all para DMs/mentions | | Bot no responde | Reglas no matchean | Verificar que hay regla catch-all para DMs/mentions |
| `no rules registered for agent` | ID no está en `rulesRegistry` | Añadir en `cmd/launcher/main.go` | | `no rules registered for agent` | ID no está en `rulesRegistry` | Añadir en `cmd/launcher/main.go` |
+153
View File
@@ -0,0 +1,153 @@
# E2EE (End-to-End Encryption) en agents_and_robots
## Resumen
Los bots Matrix usan E2EE via mautrix-go + cryptohelper para comunicarse de forma cifrada.
La implementación usa Olm puro en Go (`-tags goolm`, sin CGO).
## Arquitectura
```
config.yaml (encryption section)
agents/runtime.go → Agent.New() llama a InitCrypto()
shell/matrix/client.go → InitCrypto() configura cryptohelper
crypto.db (SQLite) — estado persistente de claves
mautrix sync loop → cifrado/descifrado transparente
```
## Qué guarda la crypto store (`crypto.db`)
| Dato | Qué es | Cuándo se crea | Cuándo rota |
|------|--------|----------------|-------------|
| **Olm Account** | Par de claves Curve25519 del dispositivo | Al primer `Init()` | Nunca — es la identidad del dispositivo |
| **One-time keys** | Claves efímeras para sesiones 1:1 | Al `Init()` y cuando se agotan | Automáticamente cuando hay < 50% disponibles |
| **Megolm sessions** | Claves de grupo para rooms | Al unirse a un room E2EE | Cada ~100 mensajes o ~1 semana |
| **Device list cache** | Claves públicas de otros dispositivos | Al hacer sync | Se actualiza con cada sync |
| **Cross-signing keys** | Master, self-signing, user-signing | Al ejecutar `cmd/verify` | Manualmente |
## Pickle key
El pickle key cifra el material criptográfico en la SQLite. Se configura por agente en `.env`:
```env
PICKLE_KEY_ASSISTANT_BOT=<hex random 32 bytes>
```
Y se referencia en `config.yaml`:
```yaml
encryption:
enabled: true
store_path: "./agents/assistant/data/crypto/"
pickle_key: "${PICKLE_KEY_ASSISTANT_BOT}"
trust_mode: tofu
```
### Generar un pickle key
```bash
openssl rand -hex 32
```
### Por qué NO derivar del access token
Si el token cambia (re-registro, nuevo login), el pickle key cambia y la DB existente
se vuelve ilegible. Esto causa el error:
```
olm account is not marked as shared, but there are keys on the server
```
Un pickle key fijo por agente en `.env` evita este problema.
## Crypto store por agente
Cada agente debe tener su propia crypto.db para evitar corrupción cruzada:
```
agents/assistant/data/crypto/crypto.db
agents/asistente2/data/crypto/crypto.db
agents/devops/data/crypto/crypto.db
```
**No compartir** la crypto store entre agentes.
## Auto-recovery
Si `cryptohelper.Init()` falla por inconsistencia (ej: "not marked as shared"),
el runtime borra automáticamente la crypto.db y reintenta. Esto regenera las claves
Olm y requiere re-verificar cross-signing.
## Cross-signing y verificación
Elimina warnings de "Encrypted by a device not verified by its owner".
```bash
go run -tags goolm ./cmd/verify \
--homeserver https://matrix-af2f3d.organic-machine.com \
--username <bot-id> \
--password <password> \
--token <access_token>
```
Esto genera y sube cross-signing keys al servidor. Si las claves ya existen,
firma el dispositivo actual con la clave existente.
## Trust mode
Configurado en `config.yaml` como `trust_mode`:
- **tofu** (Trust-on-First-Use): confía en un dispositivo la primera vez que lo ve.
Cambios posteriores generan warnings.
- **cross-signing**: requiere verificación explícita (no implementado aún).
- **manual**: cada dispositivo debe verificarse manualmente (no implementado aún).
## Build
Siempre compilar con `-tags goolm`:
```bash
go build -tags goolm -o bin/launcher ./cmd/launcher
```
El driver SQLite se registra como `"sqlite3"` via `modernc.org/sqlite` en
`cmd/launcher/sqlite.go` y `cmd/verify/sqlite.go`.
## Troubleshooting
### "olm account is not marked as shared, but there are keys on the server"
La crypto store local está desincronizada con el servidor.
**Solución**: El runtime intenta auto-recovery. Si falla manualmente:
```bash
rm agents/<id>/data/crypto/crypto.db
# Reiniciar el bot
# Re-verificar cross-signing
```
### "database is locked (SQLITE_BUSY)"
Dos procesos están accediendo la misma crypto.db simultáneamente.
**Solución**: Asegurar que cada agente use su propia `store_path` y no corran
múltiples instancias del mismo agente con E2EE habilitado sin coordinación.
### "unable to decrypt message"
Las claves Megolm de la sesión se perdieron (DB borrada o corrupta).
**Solución**: Los mensajes cifrados antes del reset son irrecuperables.
Los nuevos mensajes se descifrarán normalmente tras regenerar las claves.
## Archivos clave
| Archivo | Propósito |
|---------|-----------|
| `agents/runtime.go` | Inicializa E2EE por agente |
| `shell/matrix/client.go` | `InitCrypto()` — setup de cryptohelper |
| `cmd/verify/main.go` | Herramienta de cross-signing |
| `cmd/launcher/sqlite.go` | Registro driver SQLite |
| `internal/config/schema.go` | Schema de `EncryptionCfg` |
| `agents/*/config.yaml` | Configuración E2EE por agente |
+4 -3
View File
@@ -169,9 +169,10 @@ type MatrixCfg struct {
} }
type EncryptionCfg struct { type EncryptionCfg struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
StorePath string `yaml:"store_path"` StorePath string `yaml:"store_path"`
TrustMode string `yaml:"trust_mode"` // tofu | cross-signing | manual PickleKeyEnv string `yaml:"pickle_key_env"` // env var with hex-encoded 32-byte key
TrustMode string `yaml:"trust_mode"` // tofu | cross-signing | manual
} }
type RoomsCfg struct { type RoomsCfg struct {
+17 -7
View File
@@ -4,6 +4,7 @@ package matrix
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -44,10 +45,12 @@ func New(cfg config.MatrixCfg) (*Client, error) {
} }
// InitCrypto sets up end-to-end encryption using the mautrix cryptohelper. // InitCrypto sets up end-to-end encryption using the mautrix cryptohelper.
// storePath is the SQLite file path for crypto material (e.g. "./data/crypto/crypto.db"). // storePath is the SQLite file path for crypto material (e.g. "./agents/<id>/data/crypto/crypto.db").
// agentID is used to namespace the crypto state so multiple agents can share a database. // pickleKeyHex is a hex-encoded key for encrypting crypto material at rest. If empty,
// falls back to sha256(access_token) for backward compatibility.
// agentID namespaces the crypto state within the database.
// Returns an io.Closer that must be called on agent shutdown to flush the crypto store. // Returns an io.Closer that must be called on agent shutdown to flush the crypto store.
func (c *Client) InitCrypto(ctx context.Context, storePath, agentID string) (io.Closer, error) { func (c *Client) InitCrypto(ctx context.Context, storePath, pickleKeyHex, agentID string) (io.Closer, error) {
// Resolve the actual device ID from the server — the value in config may differ // Resolve the actual device ID from the server — the value in config may differ
// from what the registration process assigned. // from what the registration process assigned.
whoami, err := c.raw.Whoami(ctx) whoami, err := c.raw.Whoami(ctx)
@@ -56,10 +59,17 @@ func (c *Client) InitCrypto(ctx context.Context, storePath, agentID string) (io.
} }
c.raw.DeviceID = whoami.DeviceID c.raw.DeviceID = whoami.DeviceID
// Derive a stable pickle key from the access token. // Use explicit pickle key if provided, otherwise derive from access token.
// If the token changes (bot re-registered), delete the crypto store to reset. var pickleKey []byte
sum := sha256.Sum256([]byte(c.raw.AccessToken)) if pickleKeyHex != "" {
pickleKey := sum[:] pickleKey, err = hex.DecodeString(pickleKeyHex)
if err != nil {
return nil, fmt.Errorf("decode pickle_key_env: %w", err)
}
} else {
sum := sha256.Sum256([]byte(c.raw.AccessToken))
pickleKey = sum[:]
}
if err := os.MkdirAll(filepath.Dir(storePath), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(storePath), 0700); err != nil {
return nil, fmt.Errorf("create crypto store dir: %w", err) return nil, fmt.Errorf("create crypto store dir: %w", err)