Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package connector
import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"sort"
Expand All @@ -20,6 +21,7 @@ import (
"github.com/highesttt/matrix-line-messenger/pkg/connector/handlers"
"github.com/highesttt/matrix-line-messenger/pkg/e2ee"
"github.com/highesttt/matrix-line-messenger/pkg/line"
"github.com/highesttt/matrix-line-messenger/pkg/ltsm"
)

const (
Expand Down Expand Up @@ -265,8 +267,8 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
decryptionFailed = false
} else {
groupDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(err), msg, portalIDStr, opType).
Msg("DecryptGroupMessage failed, trying to fetch key")
if keyID != 0 {
Msg("DecryptGroupMessage failed")
if !errors.Is(err, ltsm.ErrAbort) && keyID != 0 {
if errFetch := lc.fetchAndUnwrapGroupKey(context.Background(), portalIDStr, keyID); errFetch != nil {
groupDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(errFetch), msg, portalIDStr, opType).
Msg("Failed to fetch/unwrap group key")
Expand All @@ -287,7 +289,10 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
} else {
directDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(err), msg, portalIDStr, opType).
Msg("DecryptMessageV2 failed on first attempt")
if _, _, errKey := lc.E2EE.MyKeyIDs(); errKey != nil {
if errors.Is(err, ltsm.ErrAbort) {
directDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(err), msg, portalIDStr, opType).
Msg("LTSM runtime aborted; skipping key refresh")
} else if _, _, errKey := lc.E2EE.MyKeyIDs(); errKey != nil {
directDecryptLogContext(lc.UserLogin.Bridge.Log.Error().Err(errKey), msg, portalIDStr, opType).
Msg("E2EE own key not loaded; cannot decrypt any messages. Re-login required")
lc.markMissingE2EEKey(context.Background(), fmt.Errorf("%w: %v", e2ee.ErrMissingOwnPrivateKey, errKey))
Expand Down
33 changes: 4 additions & 29 deletions pkg/connector/handlers/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,35 +68,10 @@ func (h *Handler) ConvertAudio(ctx context.Context, portal *bridgev2.Portal, int
return mediaDownloadFailure("Audio", err, relatesTo)
}

// Decrypt audio if it has keyMaterial (E2EE)
decrypted := false
if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") {
var decryptInfo struct {
KeyMaterial string `json:"keyMaterial"`
}
if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.KeyMaterial != "" {
decryptedAudio, err := h.DecryptMedia(audioData, decryptInfo.KeyMaterial)
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt audio data")
return nil, fmt.Errorf("failed to decrypt audio data: %w", err)
}
audioData = decryptedAudio
decrypted = true
}
}

// ENC_KM is a fallback when the in-body keyMaterial path didn't decrypt
// (e.g. E2EE chunk decryption failed). Running it unconditionally would
// double-decrypt for bridge-sent LSON audio and corrupt the bytes.
if !decrypted {
if encKM := data.ContentMetadata["ENC_KM"]; encKM != "" && len(audioData) > 32 {
decryptedAudio, err := h.DecryptMedia(audioData, encKM)
if err != nil {
h.Log.Warn().Err(err).Msg("ENC_KM fallback decrypt failed, sending raw audio")
} else {
audioData = decryptedAudio
}
}
audioData, err = h.decryptDownloadedMedia(audioData, decryptedBody, data.ContentMetadata, "audio")
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt audio data")
return nil, err
}

if oversized := h.oversizedMediaNotice(int64(len(audioData)), "downloaded", relatesTo); oversized != nil {
Expand Down
37 changes: 10 additions & 27 deletions pkg/connector/handlers/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,39 +58,22 @@ func (h *Handler) ConvertFile(ctx context.Context, portal *bridgev2.Portal, inte
return mediaDownloadFailure("File", err, relatesTo)
}

// Try to decrypt using keyMaterial from encrypted payload
var fileName string
if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") {
var decryptInfo struct {
KeyMaterial string `json:"keyMaterial"`
FileName string `json:"fileName"`
if strings.Contains(decryptedBody, "fileName") {
var fileInfo struct {
FileName string `json:"fileName"`
}
if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err != nil {
if err := json.Unmarshal([]byte(decryptedBody), &fileInfo); err != nil {
h.Log.Error().Err(err).Msg("Failed to parse file payload JSON")
return nil, fmt.Errorf("failed to parse file payload: %w", err)
}
fileName = fileInfo.FileName
}

if decryptInfo.KeyMaterial != "" {
keyPreview := decryptInfo.KeyMaterial
if len(keyPreview) > 20 {
keyPreview = keyPreview[:20] + "..."
}
h.Log.Debug().
Str("key_material_preview", keyPreview).
Msg("Decrypting file using keyMaterial from payload")

decryptedFile, err := h.DecryptMedia(fileData, decryptInfo.KeyMaterial)
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt file data")
return nil, fmt.Errorf("failed to decrypt file data: %w", err)
}
fileData = decryptedFile
h.Log.Info().Int("decrypted_size", len(fileData)).Msg("Successfully decrypted file")
}

if decryptInfo.FileName != "" {
fileName = decryptInfo.FileName
}
fileData, err = h.decryptDownloadedMedia(fileData, decryptedBody, data.ContentMetadata, "file")
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt file data")
return nil, err
}

if oversized := h.oversizedMediaNotice(int64(len(fileData)), "downloaded", relatesTo); oversized != nil {
Expand Down
46 changes: 46 additions & 0 deletions pkg/connector/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -36,6 +37,51 @@ type Handler struct {
DecryptMedia func(data []byte, keyMaterial string) ([]byte, error)
}

func (h *Handler) decryptDownloadedMedia(data []byte, decryptedBody string, metadata map[string]string, kind string) ([]byte, error) {
var bodyKey string
var bodyKeyDeclared bool
if strings.Contains(decryptedBody, "keyMaterial") {
var decryptInfo map[string]json.RawMessage
if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err != nil {
return nil, fmt.Errorf("%w: failed to parse encrypted %s payload: %w", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind), err)
}
if rawKey, ok := decryptInfo["keyMaterial"]; ok {
bodyKeyDeclared = true
if err := json.Unmarshal(rawKey, &bodyKey); err != nil {
return nil, fmt.Errorf("%w: failed to parse encrypted %s key material: %w", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind), err)
}
}
}

keys := make([]string, 0, 2)
if bodyKey != "" {
keys = append(keys, bodyKey)
}
encKM, metadataKeyDeclared := metadata["ENC_KM"]
if encKM != "" && encKM != bodyKey {
keys = append(keys, encKM)
}
if len(keys) == 0 {
if bodyKeyDeclared || metadataKeyDeclared {
return nil, fmt.Errorf("%w: encrypted %s has no usable media key", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind))
}
return data, nil
}
if h.DecryptMedia == nil {
return nil, fmt.Errorf("%w: encrypted %s has no media decryptor", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind))
}

decryptErrors := make([]error, 0, len(keys))
for _, key := range keys {
decrypted, err := h.DecryptMedia(data, key)
if err == nil {
return decrypted, nil
}
decryptErrors = append(decryptErrors, err)
}
return nil, fmt.Errorf("%w: failed to decrypt %s data: %w", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind), errors.Join(decryptErrors...))
}

func (h *Handler) downloadAlbumPreview(ctx context.Context, client *line.Client, oid, chatID, albumID string) ([]byte, error) {
if h.DownloadAlbumPreview != nil {
return h.DownloadAlbumPreview(ctx, client, oid, chatID, albumID)
Expand Down
94 changes: 94 additions & 0 deletions pkg/connector/handlers/handler_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package handlers

import (
"bytes"
"context"
"errors"
"fmt"
"testing"

"maunium.net/go/mautrix/bridgev2"
Expand All @@ -11,6 +13,98 @@ import (
"github.com/highesttt/matrix-line-messenger/pkg/line"
)

func TestDecryptDownloadedMediaUsesBodyKey(t *testing.T) {
ciphertext := []byte("ciphertext")
plaintext := []byte("plaintext")
var keys []string
h := &Handler{DecryptMedia: func(data []byte, key string) ([]byte, error) {
if !bytes.Equal(data, ciphertext) {
t.Fatalf("decrypt input = %q, want ciphertext", data)
}
keys = append(keys, key)
return plaintext, nil
}}

got, err := h.decryptDownloadedMedia(ciphertext, `{"keyMaterial":"body-key"}`, map[string]string{"ENC_KM": "metadata-key"}, "image")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("decrypted data = %q, want %q", got, plaintext)
}
if len(keys) != 1 || keys[0] != "body-key" {
t.Fatalf("keys = %v, want body key only", keys)
}
}

func TestDecryptDownloadedMediaFallsBackToENCKM(t *testing.T) {
ciphertext := []byte("ciphertext")
var keys []string
h := &Handler{DecryptMedia: func(data []byte, key string) ([]byte, error) {
if !bytes.Equal(data, ciphertext) {
t.Fatalf("decrypt input = %q, want original ciphertext", data)
}
keys = append(keys, key)
if key == "body-key" {
return nil, errors.New("body key failed")
}
return []byte("metadata plaintext"), nil
}}

got, err := h.decryptDownloadedMedia(ciphertext, `{"keyMaterial":"body-key"}`, map[string]string{"ENC_KM": "metadata-key"}, "file")
if err != nil {
t.Fatal(err)
}
if string(got) != "metadata plaintext" {
t.Fatalf("decrypted data = %q", got)
}
if fmt.Sprint(keys) != "[body-key metadata-key]" {
t.Fatalf("keys = %v, want body then metadata", keys)
}
}

func TestDecryptDownloadedMediaPassesThroughPlainMedia(t *testing.T) {
data := []byte("plain media")
got, err := new(Handler).decryptDownloadedMedia(data, "", nil, "audio")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, data) {
t.Fatalf("data = %q, want unchanged media", got)
}
}

func TestDecryptDownloadedMediaRejectsEmptyDeclaredKey(t *testing.T) {
got, err := new(Handler).decryptDownloadedMedia([]byte("ciphertext"), `{"keyMaterial":""}`, nil, "image")
if got != nil {
t.Fatalf("data = %q, want no ciphertext returned", got)
}
if !errors.Is(err, bridgev2.ErrIgnoringRemoteEvent) {
t.Fatalf("err = %v, want ErrIgnoringRemoteEvent", err)
}
}

func TestDecryptDownloadedMediaFailsClosed(t *testing.T) {
ciphertext := []byte("ciphertext")
decryptErr := errors.New("invalid media key")
var calls int
h := &Handler{DecryptMedia: func([]byte, string) ([]byte, error) {
calls++
return nil, decryptErr
}}

got, err := h.decryptDownloadedMedia(ciphertext, `{"keyMaterial":"body-key"}`, map[string]string{"ENC_KM": "metadata-key"}, "video")
if got != nil {
t.Fatalf("data = %q, want no ciphertext returned", got)
}
if calls != 2 {
t.Fatalf("decrypt calls = %d, want both declared keys attempted", calls)
}
if !errors.Is(err, decryptErr) || !errors.Is(err, bridgev2.ErrIgnoringRemoteEvent) {
t.Fatalf("err = %v, want decrypt error and ErrIgnoringRemoteEvent", err)
}
}

func TestTryRecoverClientPassesOriginatingClient(t *testing.T) {
errAuth := errors.New("SSE error: 401")
var recoverCalled bool
Expand Down
33 changes: 11 additions & 22 deletions pkg/connector/handlers/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"maunium.net/go/mautrix/bridgev2"
Expand Down Expand Up @@ -76,27 +75,17 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
return mediaDownloadFailure("Image", err, relatesTo)
}

// Decrypt image if it has keyMaterial (E2EE)
var decryptDuration time.Duration
if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") {
var decryptInfo struct {
KeyMaterial string `json:"keyMaterial"`
FileName string `json:"fileName"`
}
if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.KeyMaterial != "" {
decryptStart := time.Now()
decryptedImg, err := h.DecryptMedia(imgData, decryptInfo.KeyMaterial)
decryptDuration = time.Since(decryptStart)
if err != nil {
h.Log.Error().
Err(err).
Dur("download_duration", downloadDuration).
Dur("decrypt_duration", decryptDuration).
Msg("Failed to decrypt image data")
return nil, fmt.Errorf("failed to decrypt image data: %w", err)
}
imgData = decryptedImg
}
// Decrypt encrypted media before it can reach Matrix.
decryptStart := time.Now()
imgData, err = h.decryptDownloadedMedia(imgData, decryptedBody, data.ContentMetadata, "image")
decryptDuration := time.Since(decryptStart)
if err != nil {
h.Log.Error().
Err(err).
Dur("download_duration", downloadDuration).
Dur("decrypt_duration", decryptDuration).
Msg("Failed to decrypt image data")
return nil, err
}

if oversized := h.oversizedMediaNotice(int64(len(imgData)), "downloaded", relatesTo); oversized != nil {
Expand Down
44 changes: 4 additions & 40 deletions pkg/connector/handlers/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,46 +71,10 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int
return mediaDownloadFailure("Video", err, relatesTo)
}

decrypted := false
if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") {
var decryptInfo struct {
KeyMaterial string `json:"keyMaterial"`
FileName string `json:"fileName"`
}
if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.KeyMaterial != "" {
h.Log.Debug().
Str("key_material_len", fmt.Sprintf("%d", len(decryptInfo.KeyMaterial))).
Str("file_name", decryptInfo.FileName).
Msg("Decrypting E2EE video")

decryptedVideo, err := h.DecryptMedia(videoData, decryptInfo.KeyMaterial)
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt video data")
return nil, fmt.Errorf("failed to decrypt video data: %w", err)
}
videoData = decryptedVideo
decrypted = true
h.Log.Info().Int("decrypted_size", len(videoData)).Msg("Successfully decrypted video")
}
}

// ENC_KM is a fallback when the in-body keyMaterial path didn't decrypt
// (e.g. E2EE chunk decryption failed). Running it unconditionally would
// double-decrypt for bridge-sent LSON videos and corrupt the bytes.
if !decrypted {
if encKM := data.ContentMetadata["ENC_KM"]; encKM != "" && len(videoData) > 32 {
h.Log.Debug().
Str("enc_km_preview", encKM[:min(20, len(encKM))]+"...").
Msg("Decrypting video using ENC_KM from metadata (fallback)")

decryptedVideo, err := h.DecryptMedia(videoData, encKM)
if err != nil {
h.Log.Warn().Err(err).Msg("ENC_KM fallback decrypt failed, sending raw video")
} else {
videoData = decryptedVideo
h.Log.Info().Int("decrypted_size", len(videoData)).Msg("Successfully decrypted video from ENC_KM")
}
}
videoData, err = h.decryptDownloadedMedia(videoData, decryptedBody, data.ContentMetadata, "video")
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt video data")
return nil, err
}

if oversized := h.oversizedMediaNotice(int64(len(videoData)), "downloaded", relatesTo); oversized != nil {
Expand Down
Loading
Loading