feat: add recovery key support for E2EE agents, including configuration and documentation updates

This commit is contained in:
2026-03-05 00:56:15 +00:00
parent fc234bcb92
commit 0f900d1560
10 changed files with 284 additions and 35 deletions
+17 -2
View File
@@ -95,6 +95,7 @@ matrix:
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
recovery_key_env: SSSS_RECOVERY_KEY_<ID_UPPER> # env var con base58 recovery key
```
**Al crear un nuevo agente con E2EE:**
@@ -102,9 +103,23 @@ matrix:
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 ...`
5. Ejecutar `cmd/verify` con `--store` y `--pickle-key` del agente:
```bash
./bin/verify --homeserver "$MATRIX_HOMESERVER" --username "<id>" \
--password "$MATRIX_PASSWORD_<AGENT>" --token "$MATRIX_TOKEN_<AGENT>" \
--store "./agents/<id>/data/crypto/" --pickle-key "$PICKLE_KEY_<AGENT>"
```
6. Guardar el recovery key en `.env` (con comillas por los espacios):
```bash
SSSS_RECOVERY_KEY_<ID_UPPER>="EsXX YYYY ZZZZ ..."
```
7. Añadir `recovery_key_env` al config.yaml:
```yaml
encryption:
recovery_key_env: SSSS_RECOVERY_KEY_<ID_UPPER>
```
Ver `docs/e2ee.md` para documentación completa de E2EE.
**Sin el recovery key**, el agente arranca pero los mensajes muestran "Encrypted by a device not verified by its owner".
### 3. `agents/<agent-id>/prompts/system.md` — System prompt
+7
View File
@@ -23,6 +23,13 @@ PICKLE_KEY_ASSISTANT_BOT=
PICKLE_KEY_ASISTENTE_2=
PICKLE_KEY_DEVOPS_BOT=
# ── E2EE SSSS recovery keys (generados por cmd/verify) ──────
# Permite al agente importar cross-signing private keys al iniciar.
# Sin esto, los mensajes muestran "Encrypted by a device not verified by its owner".
SSSS_RECOVERY_KEY_ASSISTANT_BOT=
SSSS_RECOVERY_KEY_ASISTENTE_2=
SSSS_RECOVERY_KEY_DEVOPS_BOT=
# ── LLM providers ────────────────────────────────────────────
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-... # opcional, para cuando añadas el devops-bot con Claude
+2 -1
View File
@@ -116,13 +116,14 @@ matrix:
homeserver: "https://matrix-af2f3d.organic-machine.com"
user_id: "@asistente-2:matrix-af2f3d.organic-machine.com"
access_token_env: MATRIX_TOKEN_ASISTENTE2
device_id: "YBFNMNMJIC"
device_id: "XUGTSZJYFQ"
encryption:
enabled: true
store_path: "./agents/asistente2/data/crypto/"
pickle_key_env: PICKLE_KEY_ASISTENTE_2
trust_mode: tofu
recovery_key_env: SSSS_RECOVERY_KEY_ASISTENTE_2
rooms:
listen: []
+2 -1
View File
@@ -117,13 +117,14 @@ matrix:
homeserver: "https://matrix-af2f3d.organic-machine.com"
user_id: "@assistant-bot:matrix-af2f3d.organic-machine.com"
access_token_env: MATRIX_TOKEN_ASSISTANT
device_id: "ASSISTANTBOT01"
device_id: "SMWMRKMHDH"
encryption:
enabled: true
store_path: "./agents/assistant/data/crypto/"
pickle_key_env: PICKLE_KEY_ASSISTANT_BOT
trust_mode: tofu
recovery_key_env: SSSS_RECOVERY_KEY_ASSISTANT_BOT
rooms:
listen: [] # vacío = escucha en todos los rooms donde está invitado
+12
View File
@@ -56,6 +56,18 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
if err != nil {
return nil, fmt.Errorf("e2ee init: %w", err)
}
// Auto-fetch cross-signing private keys from SSSS if recovery key is configured.
if envName := cfg.Matrix.Encryption.RecoveryKeyEnv; envName != "" {
if rk := os.Getenv(envName); rk != "" {
if err := matrixClient.FetchCrossSigningKeys(context.Background(), rk); err != nil {
logger.Warn("failed to fetch cross-signing keys from SSSS (non-fatal)", "err", err)
} else {
logger.Info("cross-signing private keys fetched from SSSS")
}
}
}
logger.Info("e2ee ready")
}
+28 -9
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
@@ -23,11 +24,12 @@ import (
func main() {
var (
homeserver string
username string
password string
token string
storePath string
homeserver string
username string
password string
token string
storePath string
pickleKeyHex string
)
root := &cobra.Command{
@@ -62,9 +64,18 @@ Requires the bot's access token and password (for UIA during key upload).`,
client.DeviceID = whoami.DeviceID
fmt.Printf("→ Device ID: %s\n", client.DeviceID)
// Initialize crypto
sum := sha256.Sum256([]byte(token))
pickleKey := sum[:]
// Initialize crypto — use explicit pickle key if provided, else sha256(token)
var pickleKey []byte
if pickleKeyHex != "" {
var err error
pickleKey, err = hex.DecodeString(pickleKeyHex)
if err != nil {
return fmt.Errorf("decode pickle-key hex: %w", err)
}
} else {
sum := sha256.Sum256([]byte(token))
pickleKey = sum[:]
}
dbPath := filepath.Join(storePath, "crypto.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
@@ -91,7 +102,7 @@ Requires the bot's access token and password (for UIA during key upload).`,
}
fmt.Println("→ Generating and uploading cross-signing keys...")
_, _, err = olmMachine.GenerateAndUploadCrossSigningKeysWithPassword(ctx, password, "")
recoveryKey, _, err := olmMachine.GenerateAndUploadCrossSigningKeysWithPassword(ctx, password, "")
if err != nil {
// If keys already exist, try to just sign our device
fmt.Printf(" Note: %v\n", err)
@@ -101,6 +112,13 @@ Requires the bot's access token and password (for UIA during key upload).`,
fmt.Println("✓ Cross-signing keys uploaded successfully")
fmt.Printf("✓ Device %s is now verified by %s\n", client.DeviceID, userID)
fmt.Println()
fmt.Println("─── IMPORTANT: Save the recovery key ───")
fmt.Printf("SSSS_RECOVERY_KEY_%s=%s\n", strings.ToUpper(strings.ReplaceAll(username, "-", "_")), recoveryKey)
fmt.Println()
fmt.Println("Add this to your .env file and set recovery_key_env in the agent's config.yaml:")
fmt.Println(" encryption:")
fmt.Printf(" recovery_key_env: SSSS_RECOVERY_KEY_%s\n", strings.ToUpper(strings.ReplaceAll(username, "-", "_")))
return nil
},
}
@@ -110,6 +128,7 @@ Requires the bot's access token and password (for UIA during key upload).`,
root.Flags().StringVar(&password, "password", "", "Bot password (for UIA auth)")
root.Flags().StringVar(&token, "token", "", "Bot access token")
root.Flags().StringVar(&storePath, "store", "./data/verify-crypto/", "Crypto store path")
root.Flags().StringVar(&pickleKeyHex, "pickle-key", "", "Hex-encoded pickle key (must match agent's pickle key if sharing crypto store)")
_ = root.MarkFlagRequired("homeserver")
_ = root.MarkFlagRequired("username")
_ = root.MarkFlagRequired("password")
+66 -18
View File
@@ -208,28 +208,67 @@ Esto hace:
Sin este paso, los mensajes del bot mostrarán: **"Encrypted by a device not verified by its owner"**.
```bash
go run -tags goolm ./cmd/verify \
--homeserver "https://matrix-af2f3d.organic-machine.com" \
./bin/verify \
--homeserver "$MATRIX_HOMESERVER" \
--username "<agent-id>" \
--password "<password_del_bot>" \
--token "<access_token>" \
--store "./agents/<agent-id>/data/crypto/"
--password "$MATRIX_PASSWORD_<AGENT>" \
--token "$MATRIX_TOKEN_<AGENT>" \
--store "./agents/<agent-id>/data/crypto/" \
--pickle-key "$PICKLE_KEY_<AGENT>"
```
**Qué hace:**
1. Inicializa el crypto helper de mautrix
2. Genera claves de cross-signing (master + self-signing)
1. Inicializa el crypto helper de mautrix (usando el mismo store y pickle key que el agente)
2. Genera claves de cross-signing (master + self-signing + user-signing)
3. Las sube al homeserver usando UIA con la password del bot
4. Firma el device del bot con la self-signing key
4. Las almacena cifradas en SSSS (Server-Side Secret Storage) en el servidor
5. Imprime un **recovery key** (base58) que permite recuperar las claves privadas
**Importante:** Si se cambia la password del bot (admin API), el token anterior se invalida. Hay que:
### 5.1 Guardar el recovery key
El comando imprime algo como:
```
─── IMPORTANT: Save the recovery key ───
SSSS_RECOVERY_KEY_MI_BOT=EsXX YYYY ZZZZ ...
```
**Añadir al `.env`** (con comillas, el recovery key tiene espacios):
```bash
SSSS_RECOVERY_KEY_MI_BOT="EsXX YYYY ZZZZ ..."
```
### 5.2 Configurar recovery_key_env en config.yaml
```yaml
encryption:
enabled: true
store_path: "./agents/<agent-id>/data/crypto/"
pickle_key_env: PICKLE_KEY_<AGENT>
trust_mode: tofu
recovery_key_env: SSSS_RECOVERY_KEY_<AGENT> # ← NUEVO
```
Esto permite que el agente recupere automáticamente las cross-signing private keys desde SSSS cada vez que arranca. Sin esto, las keys solo existen en memoria durante la sesión de verify.
**Logs esperados al arrancar con recovery key configurado:**
```
INFO cross-signing private keys fetched from SSSS
INFO e2ee ready
```
### 5.3 Si se cambia la password del bot
Cambiar la password (admin API) invalida el token anterior. Hay que:
1. Re-login para obtener nuevo token
2. Actualizar `MATRIX_TOKEN_<AGENT>` en `.env`
2. Actualizar `MATRIX_TOKEN_<AGENT>` y `MATRIX_PASSWORD_<AGENT>` en `.env`
3. Actualizar `device_id` en `config.yaml`
4. Borrar el crypto store viejo (`agents/<id>/data/crypto/crypto.db`)
5. Re-ejecutar `cmd/verify`
5. Re-ejecutar `cmd/verify` → obtener nuevo recovery key
6. Actualizar `SSSS_RECOVERY_KEY_<AGENT>` en `.env`
**Nota:** El pickle key (`PICKLE_KEY_<AGENT>`) NO cambia al rotar el token. Solo se regenera si se pierde. Ver `docs/e2ee.md`.
**Nota:** El pickle key (`PICKLE_KEY_<AGENT>`) NO cambia al rotar el token. Solo se regenera si se pierde.
## Paso 6: Arrancar el agente
@@ -286,17 +325,24 @@ tail -f run/<agent-id>.log
# 5. Avatar y displayname
./dev-scripts/avatar.sh <id> static/<imagen>.jpg
# 6. Verificación E2EE
go run -tags goolm ./cmd/verify \
# 6. Generar pickle key (si no existe)
openssl rand -hex 32 # → guardar como PICKLE_KEY_<AGENT> en .env
# 7. Verificación E2EE + recovery key
./bin/verify \
--homeserver "$MATRIX_HOMESERVER" \
--username "<id>" \
--password "$MATRIX_PASSWORD_<AGENT>" \
--token "$MATRIX_TOKEN_<AGENT>"
--token "$MATRIX_TOKEN_<AGENT>" \
--store "./agents/<id>/data/crypto/" \
--pickle-key "$PICKLE_KEY_<AGENT>"
# → Guardar SSSS_RECOVERY_KEY_<AGENT> en .env (con comillas)
# → Añadir recovery_key_env al config.yaml
# 7. Arrancar
# 8. Arrancar
./dev-scripts/start.sh <id>
# 8. Verificar
# 9. Verificar
tail -f run/<id>.log
```
@@ -308,7 +354,9 @@ tail -f run/<id>.log
| `M_UNKNOWN_TOKEN` | Token invalidado (password cambiada) | Re-login, actualizar `.env` |
| `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` con `--store` y `--pickle-key` del agente, guardar recovery key en `.env` |
| `cross-signing private keys not available` | Recovery key no configurada | Ejecutar `cmd/verify`, guardar recovery key, configurar `recovery_key_env` |
| `verify recovery key: invalid` | Recovery key incorrecta | Re-ejecutar `cmd/verify` para generar nueva recovery key |
| 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` |
| Bot muere al arrancar | Revisar logs | `tail -f run/<id>.log` |
+5 -4
View File
@@ -169,10 +169,11 @@ type MatrixCfg struct {
}
type EncryptionCfg struct {
Enabled bool `yaml:"enabled"`
StorePath string `yaml:"store_path"`
PickleKeyEnv string `yaml:"pickle_key_env"` // env var with hex-encoded 32-byte key
TrustMode string `yaml:"trust_mode"` // tofu | cross-signing | manual
Enabled bool `yaml:"enabled"`
StorePath string `yaml:"store_path"`
PickleKeyEnv string `yaml:"pickle_key_env"` // env var with hex-encoded 32-byte key
TrustMode string `yaml:"trust_mode"` // tofu | cross-signing | manual
RecoveryKeyEnv string `yaml:"recovery_key_env"` // env var with base58 SSSS recovery key for cross-signing
}
type RoomsCfg struct {
+61
View File
@@ -15,6 +15,7 @@ import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/crypto/ssss"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -108,6 +109,66 @@ func (c *Client) InitCrypto(ctx context.Context, storePath, pickleKeyHex, agentI
return closer, nil
}
// ssssKeyFetcher abstracts the SSSS + cross-signing key retrieval for testing.
type ssssKeyFetcher interface {
GetDefaultKeyData(ctx context.Context) (string, ssssKeyVerifier, error)
FetchCrossSigningKeysFromSSSS(ctx context.Context, key *ssss.Key) error
}
// ssssKeyVerifier abstracts the SSSS key metadata verification.
type ssssKeyVerifier interface {
VerifyRecoveryKey(keyID, recoveryKey string) (*ssss.Key, error)
}
// olmSSSSFetcher adapts *crypto.OlmMachine to the ssssKeyFetcher interface.
type olmSSSSFetcher struct {
machine *crypto.OlmMachine
}
func (o *olmSSSSFetcher) GetDefaultKeyData(ctx context.Context) (string, ssssKeyVerifier, error) {
keyID, keyData, err := o.machine.SSSS.GetDefaultKeyData(ctx)
return keyID, keyData, err
}
func (o *olmSSSSFetcher) FetchCrossSigningKeysFromSSSS(ctx context.Context, key *ssss.Key) error {
return o.machine.FetchCrossSigningKeysFromSSSS(ctx, key)
}
// FetchCrossSigningKeys retrieves cross-signing private keys from SSSS
// (server-side secret storage) using the given base58 recovery key.
// This allows the agent to sign its own device, eliminating the
// "Encrypted by a device not verified by its owner" warning.
func (c *Client) FetchCrossSigningKeys(ctx context.Context, recoveryKey string) error {
wrapper, ok := c.raw.Crypto.(*mautrixCryptoWrapper)
if !ok || wrapper == nil {
return fmt.Errorf("crypto not initialized")
}
machine := wrapper.Machine()
if machine == nil {
return fmt.Errorf("olm machine not available")
}
return fetchCrossSigningKeysCore(ctx, &olmSSSSFetcher{machine}, recoveryKey)
}
// fetchCrossSigningKeysCore contains the testable logic for SSSS key retrieval.
func fetchCrossSigningKeysCore(ctx context.Context, fetcher ssssKeyFetcher, recoveryKey string) error {
keyID, keyData, err := fetcher.GetDefaultKeyData(ctx)
if err != nil {
return fmt.Errorf("get SSSS default key: %w", err)
}
key, err := keyData.VerifyRecoveryKey(keyID, recoveryKey)
if err != nil {
return fmt.Errorf("verify recovery key: %w", err)
}
if err := fetcher.FetchCrossSigningKeysFromSSSS(ctx, key); err != nil {
return fmt.Errorf("fetch cross-signing keys from SSSS: %w", err)
}
return nil
}
// initCryptoCore contains the testable logic: pickle key resolution, store
// creation, and auto-recovery on stale crypto.db. Returns (closer, helper, err).
func initCryptoCore(ctx context.Context, storePath, pickleKeyHex, accessToken, agentID string, initer cryptoIniter, logger *slog.Logger) (io.Closer, cryptoHelper, error) {
+84
View File
@@ -11,6 +11,7 @@ import (
"testing"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/ssss"
"maunium.net/go/mautrix/id"
)
@@ -400,3 +401,86 @@ func TestLogCryptoDiagnosticsCore_FullHappyPath(t *testing.T) {
t.Error("expected private keys log")
}
}
// --- SSSS key fetcher fakes for testing fetchCrossSigningKeysCore ---
type fakeSSSSKeyVerifier struct {
key *ssss.Key
err error
}
func (f *fakeSSSSKeyVerifier) VerifyRecoveryKey(keyID, recoveryKey string) (*ssss.Key, error) {
return f.key, f.err
}
type fakeSSSSKeyFetcher struct {
keyID string
verifier ssssKeyVerifier
getErr error
fetchErr error
}
func (f *fakeSSSSKeyFetcher) GetDefaultKeyData(ctx context.Context) (string, ssssKeyVerifier, error) {
return f.keyID, f.verifier, f.getErr
}
func (f *fakeSSSSKeyFetcher) FetchCrossSigningKeysFromSSSS(ctx context.Context, key *ssss.Key) error {
return f.fetchErr
}
func TestFetchCrossSigningKeysCore_Success(t *testing.T) {
fetcher := &fakeSSSSKeyFetcher{
keyID: "key1",
verifier: &fakeSSSSKeyVerifier{key: &ssss.Key{ID: "key1"}},
}
err := fetchCrossSigningKeysCore(context.Background(), fetcher, "valid-recovery-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchCrossSigningKeysCore_GetDefaultKeyFails(t *testing.T) {
fetcher := &fakeSSSSKeyFetcher{
getErr: errors.New("no default key"),
}
err := fetchCrossSigningKeysCore(context.Background(), fetcher, "any-key")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "get SSSS default key") {
t.Errorf("unexpected error: %v", err)
}
}
func TestFetchCrossSigningKeysCore_VerifyRecoveryKeyFails(t *testing.T) {
fetcher := &fakeSSSSKeyFetcher{
keyID: "key1",
verifier: &fakeSSSSKeyVerifier{err: errors.New("invalid recovery key")},
}
err := fetchCrossSigningKeysCore(context.Background(), fetcher, "bad-key")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "verify recovery key") {
t.Errorf("unexpected error: %v", err)
}
}
func TestFetchCrossSigningKeysCore_FetchFromSSSSFails(t *testing.T) {
fetcher := &fakeSSSSKeyFetcher{
keyID: "key1",
verifier: &fakeSSSSKeyVerifier{key: &ssss.Key{ID: "key1"}},
fetchErr: errors.New("decryption failed"),
}
err := fetchCrossSigningKeysCore(context.Background(), fetcher, "valid-key")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "fetch cross-signing keys from SSSS") {
t.Errorf("unexpected error: %v", err)
}
}