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
48 changes: 40 additions & 8 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import (
"github.com/highesttt/matrix-line-messenger/pkg/line"
)

const (
lineDecryptFallbackText = "[Unable to decrypt message. Open an issue on GitHub.]"
lineDecryptFailureNoticeText = "[Unable to decrypt LINE message.]"
)

func (lc *LineClient) newMessageHandler() *handlers.Handler {
return &handlers.Handler{
Log: lc.UserLogin.Bridge.Log,
Expand Down Expand Up @@ -121,7 +126,7 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
lc.ensureGroupMessageSenderKnown(portalIDStr, msg.From, ts)

senderID := makeUserID(msg.From)
bodyText, unwrappedText := lc.decryptMessageBody(msg, portalIDStr, opType)
bodyText, unwrappedText, decryptionFailed := lc.decryptMessageBody(msg, portalIDStr, opType)

messageEvent := &simplevent.Message[line.Message]{
EventMeta: simplevent.EventMeta{
Expand All @@ -138,7 +143,7 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
Data: *msg,
ID: networkid.MessageID(msg.ID),
ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message) (*bridgev2.ConvertedMessage, error) {
return lc.convertLineMessage(ctx, portal, intent, data, bodyText, unwrappedText)
return lc.convertLineMessage(ctx, portal, intent, data, bodyText, unwrappedText, decryptionFailed)
},
}

Expand Down Expand Up @@ -205,11 +210,12 @@ func (lc *LineClient) parseMessageTimestamp(msg *line.Message) time.Time {
// decryptMessageBody runs E2EE decryption (when needed) for an inbound message
// and returns the plaintext body plus the JSON-unwrapped text. Shared by the
// live message path (queueIncomingMessage) and the backfill path (FetchMessages).
func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string, opType int) (bodyText, unwrappedText string) {
func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string, opType int) (bodyText, unwrappedText string, decryptionFailed bool) {
// Handle Content
bodyText = msg.Text
if bodyText == "" && len(msg.Chunks) > 0 {
bodyText = "[Unable to decrypt message. Open an issue on GitHub.]"
if len(msg.Chunks) > 0 && (bodyText == "" || isLineDecryptFallbackText(bodyText)) {
Comment thread
indent-zero[bot] marked this conversation as resolved.
bodyText = ""
Comment thread
indent-zero[bot] marked this conversation as resolved.
decryptionFailed = true
if lc.E2EE != nil {
// Ensure peer keys are available before attempting decryption
lc.ensurePeerKeyForMessage(context.Background(), msg)
Expand All @@ -235,6 +241,7 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
pt, keyID, err := lc.E2EE.DecryptGroupMessage(msg, portalIDStr)
if err == nil {
bodyText = pt
decryptionFailed = false
} else {
groupDecryptLogContext(lc.UserLogin.Bridge.Log.Debug().Err(err), msg, portalIDStr, opType).
Msg("DecryptGroupMessage failed, trying to fetch key")
Expand All @@ -244,6 +251,7 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
Msg("Failed to fetch/unwrap group key")
} else if ptRetry, _, errRetry := lc.E2EE.DecryptGroupMessage(msg, portalIDStr); errRetry == nil {
bodyText = ptRetry
decryptionFailed = false
} else {
groupDecryptLogContext(lc.UserLogin.Bridge.Log.Warn().Err(errRetry), msg, portalIDStr, opType).
Msg("DecryptGroupMessage failed after group key refresh")
Expand All @@ -254,6 +262,7 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
// 1-1 Decryption
if pt, err := lc.E2EE.DecryptMessageV2(msg); err == nil {
bodyText = pt
decryptionFailed = false
} else {
lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("DecryptMessageV2 failed on first attempt")
if _, _, errKey := lc.E2EE.MyKeyIDs(); errKey != nil {
Expand Down Expand Up @@ -282,6 +291,7 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
}
if ptRetry, errRetry := lc.E2EE.DecryptMessageV2(msg); errRetry == nil {
bodyText = ptRetry
decryptionFailed = false
} else {
lc.UserLogin.Bridge.Log.Warn().Err(errRetry).Msg("DecryptMessageV2 failed on retry")
}
Expand All @@ -301,17 +311,39 @@ func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string,
}
}
}
return bodyText, unwrappedText
return bodyText, unwrappedText, decryptionFailed
}

func isLineDecryptFallbackText(text string) bool {
// LINE may include this historical fallback in Text while still sending
// encrypted chunks. Treat it as a decrypt marker, not user-authored text.
return strings.TrimSpace(text) == lineDecryptFallbackText
}

// convertLineMessage converts an inbound LINE message into a Matrix
// ConvertedMessage. bodyText/unwrappedText are the (decrypted) message text as
// returned by decryptMessageBody. Shared by the live message path and backfill.
func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, bodyText, unwrappedText string) (*bridgev2.ConvertedMessage, error) {
func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, bodyText, unwrappedText string, decryptionFailed bool) (*bridgev2.ConvertedMessage, error) {
decryptedBody := bodyText
h := lc.newMessageHandler()
replyRelatesTo := lc.resolveReplyRelatesTo(ctx, &data)

if decryptionFailed && strings.TrimSpace(unwrappedText) == "" && ContentType(data.ContentType) == ContentText {
return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{
{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: lineDecryptFailureNoticeText,
RelatesTo: replyRelatesTo,
},
},
},
}, nil
}

h := lc.newMessageHandler()

// Handle call events (ORGCONTP == "CALL")
if data.ContentMetadata["ORGCONTP"] == "CALL" {
return h.ConvertCall(data, replyRelatesTo)
Expand Down
72 changes: 72 additions & 0 deletions pkg/connector/handle_message_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package connector

import (
"testing"

"maunium.net/go/mautrix/event"

"github.com/highesttt/matrix-line-messenger/pkg/line"
)

func TestDecryptMessageBodySkipsGeneratedFallbackWhenDecryptUnavailable(t *testing.T) {
msg := &line.Message{
Text: "",
Chunks: []string{"encrypted"},
}

bodyText, unwrappedText, decryptionFailed := (&LineClient{}).decryptMessageBody(msg, "chat-mid", int(OpReceiveMessage))
if bodyText != "" || unwrappedText != "" || !decryptionFailed {
t.Fatalf("decryptMessageBody returned body=%q unwrapped=%q failed=%v, want empty strings and failed=true", bodyText, unwrappedText, decryptionFailed)
}
}

func TestDecryptMessageBodyTreatsLineFallbackAsEncryptedFailure(t *testing.T) {
msg := &line.Message{
Text: lineDecryptFallbackText,
Chunks: []string{"encrypted"},
}

bodyText, unwrappedText, decryptionFailed := (&LineClient{}).decryptMessageBody(msg, "chat-mid", int(OpReceiveMessage))
if bodyText != "" || unwrappedText != "" || !decryptionFailed {
t.Fatalf("decryptMessageBody returned body=%q unwrapped=%q failed=%v, want empty strings and failed=true", bodyText, unwrappedText, decryptionFailed)
}
}

func TestDecryptMessageBodyKeepsFallbackTextWithoutEncryptedChunks(t *testing.T) {
msg := &line.Message{
Text: lineDecryptFallbackText,
}

bodyText, unwrappedText, decryptionFailed := (&LineClient{}).decryptMessageBody(msg, "chat-mid", int(OpReceiveMessage))
if bodyText != lineDecryptFallbackText || unwrappedText != lineDecryptFallbackText || decryptionFailed {
t.Fatalf("decryptMessageBody returned body=%q unwrapped=%q failed=%v, want fallback text and failed=false", bodyText, unwrappedText, decryptionFailed)
}
}

func TestConvertLineMessageReturnsNoticeForDecryptFailure(t *testing.T) {
converted, err := (&LineClient{}).convertLineMessage(
t.Context(),
nil,
nil,
line.Message{ContentType: int(ContentText)},
"",
"",
true,
)
if err != nil {
t.Fatalf("convertLineMessage returned error: %v", err)
}
if converted == nil || len(converted.Parts) != 1 {
t.Fatalf("convertLineMessage returned %#v, want one notice part", converted)
}
content := converted.Parts[0].Content
if content.MsgType != event.MsgNotice {
t.Fatalf("MsgType = %s, want %s", content.MsgType, event.MsgNotice)
}
if content.Body != lineDecryptFailureNoticeText {
t.Fatalf("Body = %q, want %q", content.Body, lineDecryptFailureNoticeText)
}
if content.Body == lineDecryptFallbackText {
t.Fatal("notice body must not reuse LINE's historical fallback text")
}
}
4 changes: 2 additions & 2 deletions pkg/connector/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,8 @@ func (lc *LineClient) FetchMessages(ctx context.Context, params bridgev2.FetchMe
if msg.From == lc.Mid {
opType = OpSendMessage
}
bodyText, unwrappedText := lc.decryptMessageBody(msg, chatMID, int(opType))
converted, err := lc.convertLineMessage(ctx, params.Portal, intent, *msg, bodyText, unwrappedText)
bodyText, unwrappedText, decryptionFailed := lc.decryptMessageBody(msg, chatMID, int(opType))
converted, err := lc.convertLineMessage(ctx, params.Portal, intent, *msg, bodyText, unwrappedText, decryptionFailed)
if err != nil {
lc.UserLogin.Bridge.Log.Warn().Err(err).Str("msg_id", msg.ID).Str("chat_mid", chatMID).Msg("Failed to convert message for backfill")
continue
Expand Down
Loading