feat(matrix): support encrypted messages with E2EE

Merging after review. E2EE support for Matrix channel.
This commit is contained in:
daming大铭
2026-03-24 22:52:34 +08:00
committed by GitHub
11 changed files with 206 additions and 24 deletions
+4 -1
View File
@@ -23,10 +23,13 @@ jobs:
uses: golangci/golangci-lint-action@v9
with:
version: v2.10.1
args: --build-tags=goolm,stdjson
vuln_check:
name: Security Check
runs-on: ubuntu-latest
env:
GOFLAGS: -tags=goolm,stdjson
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -59,4 +62,4 @@ jobs:
run: go generate ./...
- name: Run go test
run: go test ./...
run: go test -tags goolm,stdjson ./...
+3
View File
@@ -15,6 +15,7 @@ builds:
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
@@ -57,6 +58,7 @@ builds:
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
@@ -95,6 +97,7 @@ builds:
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
+7 -6
View File
@@ -17,7 +17,8 @@ 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
GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
#
@@ -221,13 +222,13 @@ clean:
## vet: Run go vet for static analysis
vet: generate
@packages="$$(go list ./...)" && \
$(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
@cd web/backend && $(WEB_GO) vet ./...
## test: Test Go code
test: generate
@$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/)
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
@cd web && make test
## fmt: Format Go code
@@ -236,11 +237,11 @@ fmt:
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run
@CGO_ENABLED=0 $(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix
@CGO_ENABLED=0 $(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
## deps: Download dependencies
deps:
+3 -1
View File
@@ -162,7 +162,9 @@
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"crypto_database_path": "",
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
},
"line": {
"enabled": false,
+6 -1
View File
@@ -25,7 +25,9 @@ Add this to `config.json`:
"text": "Thinking..."
},
"reasoning_channel_id": "",
"message_format": "richtext"
"message_format": "richtext",
"crypto_database_path": "",
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
}
}
}
@@ -46,6 +48,8 @@ Add this to `config.json`:
| placeholder | object | No | Placeholder message config |
| reasoning_channel_id | string | No | Target channel for reasoning output |
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
| crypto_database_path | string | No | Path to store the crypto database (uses workspace path `~/.picoclaw/workspace` if empty) |
| crypto_passphrase | string | No | Serialization key for encrypting session keys in the database; must remain unchanged once set |
## 3. Currently Supported
@@ -58,6 +62,7 @@ Add this to `config.json`:
- Typing state (`m.typing`)
- Placeholder message + final reply replacement
- Auto-join invited rooms (can be disabled)
- End-to-end encryption (E2EE) support for encrypted messages
## 4. TODO
+7 -1
View File
@@ -24,7 +24,10 @@
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"message_format": "richtext",
"crypto_database_path": "",
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
}
}
}
@@ -45,6 +48,8 @@
| placeholder | object | 否 | 占位消息配置 |
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
| message_format | string | 否 | 消息格式:`richtext`(富文本)或 `plain`(纯文本) |
| crypto_database_path | string | 否 | 加密数据库存储路径(为空时使用工作空间路径 `~/.picoclaw/workspace` |
| crypto_passphrase | string | 否 | 加密数据库中 session key 的序列化密钥;设置后不能更改 |
## 3. 当前支持
@@ -56,6 +61,7 @@
- Typing 状态(`m.typing`
- 占位消息(`Thinking... 💭`+ 最终回复替换
- 自动加入邀请房间(可关闭)
- 端对端加密(E2EE)消息支持
## 4. TODO
+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)
})
}
+154 -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,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
View File
@@ -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
+2
View File
@@ -113,6 +113,8 @@ func DefaultConfig() *Config {
Enabled: true,
Text: "Thinking... 💭",
},
CryptoDatabasePath: "",
CryptoPassphrase: "",
},
LINE: LINEConfig{
Enabled: false,