diff --git a/.claude/policies/create_agent.md b/.claude/policies/create_agent.md index 5e6c87d..e7bd4fe 100644 --- a/.claude/policies/create_agent.md +++ b/.claude/policies/create_agent.md @@ -95,6 +95,7 @@ matrix: store_path: "./agents//data/crypto/" # SIEMPRE por agente, nunca compartida pickle_key_env: PICKLE_KEY_ # env var con clave hex trust_mode: tofu + recovery_key_env: SSSS_RECOVERY_KEY_ # 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_=` 3. Añadir a `.env.example`: `PICKLE_KEY_=` 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 "" \ + --password "$MATRIX_PASSWORD_" --token "$MATRIX_TOKEN_" \ + --store "./agents//data/crypto/" --pickle-key "$PICKLE_KEY_" + ``` +6. Guardar el recovery key en `.env` (con comillas por los espacios): + ```bash + SSSS_RECOVERY_KEY_="EsXX YYYY ZZZZ ..." + ``` +7. Añadir `recovery_key_env` al config.yaml: + ```yaml + encryption: + recovery_key_env: SSSS_RECOVERY_KEY_ + ``` -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//prompts/system.md` — System prompt diff --git a/.env.example b/.env.example index 1d8f6bf..ec48a4a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/agents/asistente2/config.yaml b/agents/asistente2/config.yaml index 6c05706..a244bbf 100644 --- a/agents/asistente2/config.yaml +++ b/agents/asistente2/config.yaml @@ -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: [] diff --git a/agents/assistant/config.yaml b/agents/assistant/config.yaml index f163559..c468451 100644 --- a/agents/assistant/config.yaml +++ b/agents/assistant/config.yaml @@ -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 diff --git a/agents/runtime.go b/agents/runtime.go index cad8693..7a3384e 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -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") } diff --git a/cmd/verify/main.go b/cmd/verify/main.go index bff5d12..f7d94a1 100644 --- a/cmd/verify/main.go +++ b/cmd/verify/main.go @@ -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") diff --git a/docs/creating-agents.md b/docs/creating-agents.md index 942ef4c..92c3810 100644 --- a/docs/creating-agents.md +++ b/docs/creating-agents.md @@ -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 "" \ - --password "" \ - --token "" \ - --store "./agents//data/crypto/" + --password "$MATRIX_PASSWORD_" \ + --token "$MATRIX_TOKEN_" \ + --store "./agents//data/crypto/" \ + --pickle-key "$PICKLE_KEY_" ``` **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//data/crypto/" + pickle_key_env: PICKLE_KEY_ + trust_mode: tofu + recovery_key_env: SSSS_RECOVERY_KEY_ # ← 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_` en `.env` +2. Actualizar `MATRIX_TOKEN_` y `MATRIX_PASSWORD_` en `.env` 3. Actualizar `device_id` en `config.yaml` 4. Borrar el crypto store viejo (`agents//data/crypto/crypto.db`) -5. Re-ejecutar `cmd/verify` +5. Re-ejecutar `cmd/verify` → obtener nuevo recovery key +6. Actualizar `SSSS_RECOVERY_KEY_` en `.env` -**Nota:** El pickle key (`PICKLE_KEY_`) NO cambia al rotar el token. Solo se regenera si se pierde. Ver `docs/e2ee.md`. +**Nota:** El pickle key (`PICKLE_KEY_`) NO cambia al rotar el token. Solo se regenera si se pierde. ## Paso 6: Arrancar el agente @@ -286,17 +325,24 @@ tail -f run/.log # 5. Avatar y displayname ./dev-scripts/avatar.sh static/.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_ en .env + +# 7. Verificación E2EE + recovery key +./bin/verify \ --homeserver "$MATRIX_HOMESERVER" \ --username "" \ --password "$MATRIX_PASSWORD_" \ - --token "$MATRIX_TOKEN_" + --token "$MATRIX_TOKEN_" \ + --store "./agents//data/crypto/" \ + --pickle-key "$PICKLE_KEY_" +# → Guardar SSSS_RECOVERY_KEY_ en .env (con comillas) +# → Añadir recovery_key_env al config.yaml -# 7. Arrancar +# 8. Arrancar ./dev-scripts/start.sh -# 8. Verificar +# 9. Verificar tail -f run/.log ``` @@ -308,7 +354,9 @@ tail -f run/.log | `M_UNKNOWN_TOKEN` | Token invalidado (password cambiada) | Re-login, actualizar `.env` | | `mismatching device ID` | Crypto store con device viejo | Borrar `agents//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/.log` | diff --git a/internal/config/schema.go b/internal/config/schema.go index 94acdaf..da9a7eb 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -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 { diff --git a/shell/matrix/client.go b/shell/matrix/client.go index c47e898..abd241e 100644 --- a/shell/matrix/client.go +++ b/shell/matrix/client.go @@ -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) { diff --git a/shell/matrix/client_test.go b/shell/matrix/client_test.go index a5da574..da7a17c 100644 --- a/shell/matrix/client_test.go +++ b/shell/matrix/client_test.go @@ -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) + } +}