mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(matrix): support encrypted messages with E2EE
Merging after review. E2EE support for Matrix channel.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
@@ -8,6 +10,11 @@ import (
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewMatrixChannel(cfg.Channels.Matrix, b)
|
||||
matrixCfg := cfg.Channels.Matrix
|
||||
cryptoDatabasePath := matrixCfg.CryptoDatabasePath
|
||||
if cryptoDatabasePath == "" {
|
||||
cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix")
|
||||
}
|
||||
return NewMatrixChannel(matrixCfg, b, cryptoDatabasePath)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
@@ -17,9 +18,12 @@ import (
|
||||
"github.com/gomarkdown/markdown"
|
||||
mdhtml "github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
@@ -30,6 +34,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteDriver = "sqlite"
|
||||
dbName = "store.db"
|
||||
|
||||
typingRefreshInterval = 20 * time.Second
|
||||
typingServerTTL = 30 * time.Second
|
||||
roomKindCacheTTL = 5 * time.Minute
|
||||
@@ -181,9 +188,16 @@ type MatrixChannel struct {
|
||||
|
||||
roomKindCache *roomKindCache
|
||||
localpartMentionR *regexp.Regexp
|
||||
|
||||
cryptoHelper *cryptohelper.CryptoHelper
|
||||
cryptoDbPath string
|
||||
}
|
||||
|
||||
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
|
||||
func NewMatrixChannel(
|
||||
cfg config.MatrixConfig,
|
||||
messageBus *bus.MessageBus,
|
||||
cryptoDatabasePath string,
|
||||
) (*MatrixChannel, error) {
|
||||
homeserver := strings.TrimSpace(cfg.Homeserver)
|
||||
userID := strings.TrimSpace(cfg.UserID)
|
||||
accessToken := strings.TrimSpace(cfg.AccessToken())
|
||||
@@ -230,6 +244,7 @@ func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*Mat
|
||||
roomKindCache: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL),
|
||||
localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)),
|
||||
typingMu: sync.Mutex{},
|
||||
cryptoDbPath: cryptoDatabasePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -239,7 +254,21 @@ func (c *MatrixChannel) Start(ctx context.Context) error {
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
c.startTime = time.Now()
|
||||
|
||||
// Initialize crypto helper if database and passphrase are configured
|
||||
if c.cryptoDbPath != "" && c.config.CryptoPassphrase != "" {
|
||||
if err := c.initCrypto(ctx); err != nil {
|
||||
logger.WarnCF(
|
||||
"matrix",
|
||||
"Failed to initialize crypto, continuing without encryption support",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent)
|
||||
c.syncer.OnEventType(event.EventEncrypted, c.handleMessageEvent)
|
||||
c.syncer.OnEventType(event.StateMember, c.handleMemberEvent)
|
||||
|
||||
c.SetRunning(true)
|
||||
@@ -266,10 +295,84 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
|
||||
}
|
||||
c.stopTypingSessions(ctx)
|
||||
|
||||
// Close crypto helper if initialized
|
||||
if c.cryptoHelper != nil {
|
||||
c.cryptoHelper.Close()
|
||||
c.cryptoHelper = nil
|
||||
c.client.Crypto = nil
|
||||
}
|
||||
|
||||
logger.InfoC("matrix", "Matrix channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MatrixChannel) initCrypto(ctx context.Context) error {
|
||||
logger.InfoC("matrix", "Initializing crypto helper")
|
||||
|
||||
// Ensure the crypto database directory exists
|
||||
if err := os.MkdirAll(c.cryptoDbPath, 0o700); err != nil {
|
||||
return fmt.Errorf("create crypto database directory: %w", err)
|
||||
}
|
||||
|
||||
// Create database with sqlite driver (modernc.org/sqlite)
|
||||
dbPath := filepath.Join(c.cryptoDbPath, dbName)
|
||||
connStr := "file:" + dbPath + "?_foreign_keys=on"
|
||||
|
||||
db, err := sql.Open(sqliteDriver, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open crypto database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
// Execute PRAGMA statements
|
||||
// This is equivalent to the "sqlite3-fk-wal" dialect used by cryptohelper
|
||||
pragmaStmts := []string{
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
}
|
||||
for _, pragma := range pragmaStmts {
|
||||
if _, err = db.ExecContext(ctx, pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("execute %s: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap with dbutil for dialect support
|
||||
wrappedDB, err := dbutil.NewWithDB(db, sqliteDriver)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("wrap database: %w", err)
|
||||
}
|
||||
|
||||
cryptoHelper, err := cryptohelper.NewCryptoHelper(c.client, []byte(c.config.CryptoPassphrase), wrappedDB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create crypto helper: %w", err)
|
||||
}
|
||||
|
||||
if c.client.DeviceID == "" {
|
||||
resp, whoamiErr := c.client.Whoami(ctx)
|
||||
if whoamiErr != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("get device ID via whoami: %w", whoamiErr)
|
||||
}
|
||||
c.client.DeviceID = resp.DeviceID
|
||||
}
|
||||
|
||||
if err = cryptoHelper.Init(ctx); err != nil {
|
||||
cryptoHelper.Close()
|
||||
return fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
c.client.Crypto = cryptoHelper
|
||||
c.cryptoHelper = cryptoHelper
|
||||
|
||||
logger.InfoC("matrix", "Crypto helper initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownToHTML(md string) string {
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
|
||||
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
|
||||
@@ -548,9 +651,26 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
|
||||
return
|
||||
}
|
||||
|
||||
msgEvt := evt.Content.AsMessage()
|
||||
if msgEvt == nil {
|
||||
return
|
||||
var msgEvt *event.MessageEventContent
|
||||
switch evt.Type {
|
||||
case event.EventMessage:
|
||||
// When crypto is enabled, events marked WasEncrypted=true are
|
||||
// re-dispatched by c.cryptoHelper after decryption and will be
|
||||
// processed again in the EventEncrypted branch. Skip to avoid duplication.
|
||||
if c.client.Crypto != nil && evt.Mautrix.WasEncrypted {
|
||||
return
|
||||
}
|
||||
|
||||
msgEvt = evt.Content.AsMessage()
|
||||
if msgEvt == nil || msgEvt.MsgType == "" {
|
||||
return
|
||||
}
|
||||
case event.EventEncrypted:
|
||||
var ok bool
|
||||
msgEvt, ok = c.decryptEvent(ctx, evt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore edits.
|
||||
@@ -642,6 +762,36 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
|
||||
)
|
||||
}
|
||||
|
||||
// decryptEvent decrypts an encrypted event and returns the decrypted message event content.
|
||||
// It returns the decrypted content and a boolean indicating whether decryption was successful.
|
||||
func (c *MatrixChannel) decryptEvent(ctx context.Context, evt *event.Event) (*event.MessageEventContent, bool) {
|
||||
if c.client.Crypto == nil {
|
||||
logger.DebugCF("matrix", "Received encrypted message but crypto is not enabled", map[string]any{
|
||||
"room_id": evt.RoomID.String(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
decrypted, err := c.client.Crypto.Decrypt(ctx, evt)
|
||||
if err != nil {
|
||||
logger.WarnCF("matrix", "Failed to decrypt message", map[string]any{
|
||||
"room_id": evt.RoomID.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if decrypted.Type != event.EventMessage {
|
||||
logger.DebugCF("matrix", "Decrypted event is not a message event", map[string]any{
|
||||
"room_id": evt.RoomID.String(),
|
||||
"type": decrypted.Type.String(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return decrypted.Content.AsMessage(), true
|
||||
}
|
||||
|
||||
func (c *MatrixChannel) extractInboundContent(
|
||||
ctx context.Context,
|
||||
msgEvt *event.MessageEventContent,
|
||||
|
||||
+10
-8
@@ -591,18 +591,20 @@ func (c *SlackConfig) SetAppToken(token string) {
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||
accessToken string
|
||||
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||
secDirty bool
|
||||
CryptoDatabasePath string `json:"crypto_database_path,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_DATABASE_PATH"`
|
||||
CryptoPassphrase string `json:"crypto_passphrase,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_PASSPHRASE"`
|
||||
}
|
||||
|
||||
// AccessToken returns the Matrix access token
|
||||
|
||||
@@ -113,6 +113,8 @@ func DefaultConfig() *Config {
|
||||
Enabled: true,
|
||||
Text: "Thinking... 💭",
|
||||
},
|
||||
CryptoDatabasePath: "",
|
||||
CryptoPassphrase: "",
|
||||
},
|
||||
LINE: LINEConfig{
|
||||
Enabled: false,
|
||||
|
||||
Reference in New Issue
Block a user