29decb3321
Se configura goldmark con extensiones GFM (tablas, strikethrough, autolinks, task lists), DefinitionList, Footnote, Typographer y CJK. Esto reemplaza el parser básico por defecto por uno con soporte completo de Markdown, mejorando el rendering de mensajes enviados por los bots en Matrix. Se reutiliza una instancia global del parser para evitar recrearlo en cada llamada. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
455 lines
15 KiB
Go
455 lines
15 KiB
Go
// Package matrix wraps mautrix-go for agent use.
|
|
package matrix
|
|
|
|
import (
|
|
"context"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
"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"
|
|
|
|
"github.com/enmanuel/agents/internal/config"
|
|
)
|
|
|
|
// Client wraps a mautrix client with agent-relevant helpers.
|
|
type Client struct {
|
|
raw *mautrix.Client
|
|
cfg config.MatrixCfg
|
|
}
|
|
|
|
// New creates and authenticates a Matrix client from config.
|
|
// The access token is read from the env var specified in cfg.AccessTokenEnv.
|
|
func New(cfg config.MatrixCfg) (*Client, error) {
|
|
token := os.Getenv(cfg.AccessTokenEnv)
|
|
if token == "" {
|
|
return nil, fmt.Errorf("env var %s is not set", cfg.AccessTokenEnv)
|
|
}
|
|
|
|
raw, err := mautrix.NewClient(cfg.Homeserver, id.UserID(cfg.UserID), token)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create matrix client: %w", err)
|
|
}
|
|
|
|
if cfg.DeviceID != "" {
|
|
raw.DeviceID = id.DeviceID(cfg.DeviceID)
|
|
}
|
|
|
|
return &Client{raw: raw, cfg: cfg}, nil
|
|
}
|
|
|
|
// cryptoIniter abstracts crypto helper creation for testing.
|
|
type cryptoIniter interface {
|
|
newHelper(pickleKey []byte, storePath string) (cryptoHelper, error)
|
|
}
|
|
|
|
// cryptoHelper abstracts the mautrix CryptoHelper for testing.
|
|
type cryptoHelper interface {
|
|
io.Closer
|
|
Init(ctx context.Context) error
|
|
SetAccountID(id string)
|
|
}
|
|
|
|
// mautrixCryptoIniter is the real implementation using mautrix.
|
|
type mautrixCryptoIniter struct {
|
|
raw *mautrix.Client
|
|
}
|
|
|
|
func (m *mautrixCryptoIniter) newHelper(pickleKey []byte, storePath string) (cryptoHelper, error) {
|
|
h, err := cryptohelper.NewCryptoHelper(m.raw, pickleKey, storePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mautrixCryptoWrapper{h}, nil
|
|
}
|
|
|
|
type mautrixCryptoWrapper struct {
|
|
*cryptohelper.CryptoHelper
|
|
}
|
|
|
|
func (w *mautrixCryptoWrapper) SetAccountID(id string) { w.DBAccountID = id }
|
|
|
|
// InitCrypto sets up end-to-end encryption using the mautrix cryptohelper.
|
|
// storePath is the SQLite file path for crypto material (e.g. "./agents/<id>/data/crypto/crypto.db").
|
|
// 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.
|
|
func (c *Client) InitCrypto(ctx context.Context, storePath, pickleKeyHex, agentID string) (io.Closer, error) {
|
|
// Resolve the actual device ID from the server.
|
|
whoami, err := c.raw.Whoami(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("whoami for crypto init: %w", err)
|
|
}
|
|
c.raw.DeviceID = whoami.DeviceID
|
|
|
|
initer := &mautrixCryptoIniter{raw: c.raw}
|
|
closer, helper, err := initCryptoCore(ctx, storePath, pickleKeyHex, c.raw.AccessToken, agentID, initer, slog.Default())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Assign the real mautrix crypto helper — this satisfies mautrix.CryptoHelper.
|
|
wrapper := helper.(*mautrixCryptoWrapper)
|
|
c.raw.Crypto = wrapper
|
|
|
|
// Log E2EE diagnostic state to help debug verification issues.
|
|
logCryptoDiagnostics(ctx, wrapper, c.raw, slog.Default())
|
|
|
|
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)
|
|
}
|
|
|
|
// SignOwnDevice signs the bot's current device with the self-signing key.
|
|
// This is the step that makes Element show the device as "verified".
|
|
// Must be called after cross-signing private keys are available (via
|
|
// FetchCrossSigningKeys or GenerateAndUploadCrossSigningKeys).
|
|
// It force-fetches device keys from the server first to ensure the local
|
|
// store has the correct signing key.
|
|
func (c *Client) SignOwnDevice(ctx context.Context) 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")
|
|
}
|
|
|
|
// Force-fetch own device keys so the local store has the correct signing key.
|
|
// Without this, SignOwnDevice fails with "different signing key" when the
|
|
// store has a stale or empty entry.
|
|
devices, err := machine.FetchKeys(ctx, []id.UserID{c.raw.UserID}, true)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch own device keys: %w", err)
|
|
}
|
|
userDevices, ok := devices[c.raw.UserID]
|
|
if !ok {
|
|
return fmt.Errorf("own user not found in fetched keys")
|
|
}
|
|
device, ok := userDevices[c.raw.DeviceID]
|
|
if !ok {
|
|
return fmt.Errorf("own device %s not found in fetched keys", c.raw.DeviceID)
|
|
}
|
|
|
|
return machine.SignOwnDevice(ctx, device)
|
|
}
|
|
|
|
// 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) {
|
|
pickleKey, err := resolvePickleKey(pickleKeyHex, accessToken)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(storePath), 0700); err != nil {
|
|
return nil, nil, fmt.Errorf("create crypto store dir: %w", err)
|
|
}
|
|
|
|
helper, err := initHelper(ctx, initer, pickleKey, storePath, agentID)
|
|
if err != nil && strings.Contains(err.Error(), "not marked as shared") {
|
|
logger.Warn("crypto store inconsistent, attempting auto-recovery",
|
|
"store", storePath,
|
|
)
|
|
if removeErr := os.Remove(storePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
|
return nil, nil, fmt.Errorf("auto-recovery: remove stale crypto.db: %w (original: %w)", removeErr, err)
|
|
}
|
|
helper, err = initHelper(ctx, initer, pickleKey, storePath, agentID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("e2ee init after auto-recovery: %w", err)
|
|
}
|
|
logger.Info("e2ee auto-recovery succeeded")
|
|
} else if err != nil {
|
|
return nil, nil, fmt.Errorf("init e2ee: %w", err)
|
|
}
|
|
|
|
return helper, helper, nil
|
|
}
|
|
|
|
func initHelper(ctx context.Context, initer cryptoIniter, pickleKey []byte, storePath, agentID string) (cryptoHelper, error) {
|
|
helper, err := initer.newHelper(pickleKey, storePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create crypto helper: %w", err)
|
|
}
|
|
helper.SetAccountID(agentID)
|
|
if err := helper.Init(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return helper, nil
|
|
}
|
|
|
|
// resolvePickleKey decodes a hex key or derives one from the access token.
|
|
func resolvePickleKey(pickleKeyHex, accessToken string) ([]byte, error) {
|
|
if pickleKeyHex != "" {
|
|
key, err := hex.DecodeString(pickleKeyHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode pickle_key_env: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
sum := sha256.Sum256([]byte(accessToken))
|
|
return sum[:], nil
|
|
}
|
|
|
|
// SendText sends a plain-text message to a room.
|
|
// If the room has E2EE enabled and crypto is initialized, the message is encrypted automatically.
|
|
func (c *Client) SendText(ctx context.Context, roomID, text string) error {
|
|
_, err := c.raw.SendText(ctx, id.RoomID(roomID), text)
|
|
return err
|
|
}
|
|
|
|
// SendMarkdown sends a formatted (Markdown) message to a room.
|
|
// Body contains the raw markdown (plaintext fallback per Matrix spec).
|
|
// FormattedBody contains the HTML rendered by goldmark.
|
|
func (c *Client) SendMarkdown(ctx context.Context, roomID, markdown string) error {
|
|
html := mdToHTML(markdown)
|
|
content := event.MessageEventContent{
|
|
MsgType: event.MsgText,
|
|
Body: markdown,
|
|
Format: event.FormatHTML,
|
|
FormattedBody: html,
|
|
}
|
|
_, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
|
|
return err
|
|
}
|
|
|
|
// mdToHTML converts a Markdown string to HTML using goldmark with full extensions.
|
|
var mdParser = goldmark.New(
|
|
goldmark.WithExtensions(
|
|
extension.GFM,
|
|
extension.DefinitionList,
|
|
extension.Footnote,
|
|
extension.Typographer,
|
|
extension.CJK,
|
|
),
|
|
)
|
|
|
|
func mdToHTML(md string) string {
|
|
var buf bytes.Buffer
|
|
if err := mdParser.Convert([]byte(md), &buf); err != nil {
|
|
return md // fallback to raw markdown on error
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// SendReaction sends a reaction to an event.
|
|
func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error {
|
|
_, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction)
|
|
return err
|
|
}
|
|
|
|
// SendTyping sets the typing indicator in a room.
|
|
func (c *Client) SendTyping(ctx context.Context, roomID string, typing bool) error {
|
|
_, err := c.raw.UserTyping(ctx, id.RoomID(roomID), typing, 5000)
|
|
return err
|
|
}
|
|
|
|
// diagMachine abstracts the crypto.OlmMachine methods used by diagnostics,
|
|
// allowing unit tests to substitute a fake without a real crypto store.
|
|
type diagMachine interface {
|
|
GetOwnCrossSigningPublicKeys(ctx context.Context) *crypto.CrossSigningPublicKeysCache
|
|
OwnIdentity() *id.Device
|
|
ExportCrossSigningKeys() crypto.CrossSigningSeeds
|
|
ResolveTrustContext(ctx context.Context, device *id.Device) (id.TrustState, error)
|
|
IsDeviceTrusted(device *id.Device) bool
|
|
}
|
|
|
|
// logCryptoDiagnostics logs the E2EE state after initialization.
|
|
// This helps diagnose "Encrypted by a device not verified by its owner" warnings.
|
|
// It is defensive against nil fields in the crypto machine to avoid panics.
|
|
func logCryptoDiagnostics(ctx context.Context, wrapper *mautrixCryptoWrapper, raw *mautrix.Client, logger *slog.Logger) {
|
|
machine := wrapper.Machine()
|
|
if machine == nil {
|
|
logger.Warn("e2ee diagnostics: olm machine is nil")
|
|
return
|
|
}
|
|
logCryptoDiagnosticsCore(ctx, machine, raw.UserID, raw.DeviceID, logger)
|
|
}
|
|
|
|
// logCryptoDiagnosticsCore contains the testable diagnostics logic.
|
|
func logCryptoDiagnosticsCore(ctx context.Context, machine diagMachine, userID id.UserID, deviceID id.DeviceID, logger *slog.Logger) {
|
|
logger.Info("e2ee diagnostics: device info",
|
|
"user_id", userID,
|
|
"device_id", deviceID,
|
|
)
|
|
|
|
// Check own cross-signing public keys
|
|
ownKeys := machine.GetOwnCrossSigningPublicKeys(ctx)
|
|
if ownKeys == nil {
|
|
logger.Warn("e2ee diagnostics: NO cross-signing public keys found — this is likely why messages show 'not verified by its owner'",
|
|
"hint", "run: go run -tags goolm ./cmd/verify --homeserver <hs> --username <user> --password <pass> --token <token>",
|
|
)
|
|
} else {
|
|
logger.Info("e2ee diagnostics: cross-signing public keys found",
|
|
"master_key", truncateKey(string(ownKeys.MasterKey)),
|
|
"self_signing_key", truncateKey(string(ownKeys.SelfSigningKey)),
|
|
"user_signing_key", truncateKey(string(ownKeys.UserSigningKey)),
|
|
)
|
|
}
|
|
|
|
// Check if our own device is trusted via cross-signing
|
|
ownDevice := machine.OwnIdentity()
|
|
if ownDevice == nil {
|
|
logger.Warn("e2ee diagnostics: own device identity is nil — cannot check trust state")
|
|
} else {
|
|
logDeviceTrust(ctx, machine, ownDevice, logger)
|
|
}
|
|
|
|
// Check if cross-signing private keys are available (needed to sign devices).
|
|
// ExportCrossSigningKeys panics when CrossSigningKeys is nil (no private keys
|
|
// loaded), so we guard the call.
|
|
logCrossSigningSeeds(machine, logger)
|
|
}
|
|
|
|
// logCrossSigningSeeds safely exports and logs cross-signing private key availability.
|
|
// mautrix's ExportCrossSigningKeys panics when CrossSigningKeys is nil, so we
|
|
// recover from the panic instead of relying on unexported internal fields.
|
|
func logCrossSigningSeeds(machine diagMachine, logger *slog.Logger) {
|
|
var (
|
|
hasMasterSeed bool
|
|
hasSelfSigningSeed bool
|
|
hasUserSigningSeed bool
|
|
recovered bool
|
|
)
|
|
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
recovered = true
|
|
}
|
|
}()
|
|
seeds := machine.ExportCrossSigningKeys()
|
|
hasMasterSeed = len(seeds.MasterKey) > 0
|
|
hasSelfSigningSeed = len(seeds.SelfSigningKey) > 0
|
|
hasUserSigningSeed = len(seeds.UserSigningKey) > 0
|
|
}()
|
|
|
|
if recovered {
|
|
logger.Warn("e2ee diagnostics: cross-signing private keys not available (not loaded in crypto store)")
|
|
return
|
|
}
|
|
|
|
logger.Info("e2ee diagnostics: cross-signing private keys in store",
|
|
"master_seed", hasMasterSeed,
|
|
"self_signing_seed", hasSelfSigningSeed,
|
|
"user_signing_seed", hasUserSigningSeed,
|
|
)
|
|
|
|
if !hasSelfSigningSeed {
|
|
logger.Warn("e2ee diagnostics: self-signing private key NOT in store — the bot cannot sign its own device",
|
|
"hint", "run cmd/verify with the SAME crypto store path the agent uses",
|
|
)
|
|
}
|
|
}
|
|
|
|
// logDeviceTrust resolves and logs the trust state for a device.
|
|
func logDeviceTrust(ctx context.Context, machine diagMachine, device *id.Device, logger *slog.Logger) {
|
|
trust, err := machine.ResolveTrustContext(ctx, device)
|
|
if err != nil {
|
|
logger.Warn("e2ee diagnostics: failed to resolve device trust",
|
|
"device_id", device.DeviceID,
|
|
"err", err,
|
|
)
|
|
return
|
|
}
|
|
|
|
logger.Info("e2ee diagnostics: own device trust state",
|
|
"device_id", device.DeviceID,
|
|
"trust_state", trust.String(),
|
|
"is_trusted", machine.IsDeviceTrusted(device),
|
|
)
|
|
|
|
if trust < id.TrustStateCrossSignedTOFU {
|
|
logger.Warn("e2ee diagnostics: device is NOT cross-signed — recipients will see 'not verified by its owner'",
|
|
"trust_state", trust.String(),
|
|
"required_minimum", "CrossSignedTOFU",
|
|
)
|
|
}
|
|
}
|
|
|
|
// truncateKey returns first 8 chars of a key for safe logging.
|
|
func truncateKey(key string) string {
|
|
if len(key) > 8 {
|
|
return key[:8] + "..."
|
|
}
|
|
return key
|
|
}
|
|
|
|
// Raw returns the underlying mautrix.Client for advanced use.
|
|
func (c *Client) Raw() *mautrix.Client {
|
|
return c.raw
|
|
}
|