feat(matrix): support encrypted messages with E2EE

- Add `crypto_database_path` and `crypto_passphrase` configuration
- Integrate cryptohelper for decrypting `m.room.encrypted` events
- Handle both plaintext and encrypted messages in `handleMessageEvent`
- Enable `goolm` build tag for libsignal crypto support

Fixes #1840.
This commit is contained in:
RussellLuo
2026-03-20 23:24:46 +08:00
parent 4d7a629b79
commit fab9603547
5 changed files with 159 additions and 7 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COM
# Go variables
GO?=CGO_ENABLED=0 go
WEB_GO?=$(GO)
GOFLAGS?=-v -tags stdjson
GOFLAGS?=-v -tags goolm,stdjson
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
#
+2 -1
View File
@@ -20,6 +20,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mattn/go-sqlite3 v1.14.34
github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/mymmrac/telego v1.7.0
@@ -31,6 +32,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/util v0.9.7
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
@@ -77,7 +79,6 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.7 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.67.6 // indirect
+8 -1
View File
@@ -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)
})
}
+146 -4
View File
@@ -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,12 @@ 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 +240,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 +250,17 @@ 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 +287,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, err := c.client.Whoami(ctx)
if err != nil {
_ = db.Close()
return fmt.Errorf("get device ID via whoami: %w", err)
}
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 +643,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 +754,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,
+2
View File
@@ -603,6 +603,8 @@ type MatrixConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
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