From e39baa81fc05c4bc418e71b4945d3d769d4c5e09 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Fri, 23 Oct 2026 12:00:51 +0000 Subject: [PATCH] e2ee: encrypt group messages with the group key's spec version LINE stores a Letter Sealing spec version on each chat's group key. When it is 1, LINE rejects V2 messages with TalkException 82 "specVersion does not match". Encrypt V1 for those chats, as the LINE Chrome extension does, and set e2eeVersion from the chunks we actually send. --- pkg/connector/e2ee_keys.go | 1 + pkg/connector/send_message.go | 3 +++ pkg/e2ee/manager.go | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/pkg/connector/e2ee_keys.go b/pkg/connector/e2ee_keys.go index 9d902d24..04392fe1 100644 --- a/pkg/connector/e2ee_keys.go +++ b/pkg/connector/e2ee_keys.go @@ -73,6 +73,7 @@ func (lc *LineClient) fetchAndUnwrapGroupKey(ctx context.Context, chatMid string Int("group_key_id", sharedKey.GroupKeyID). Int("creator_key_id", sharedKey.CreatorKeyID). Int("receiver_key_id", sharedKey.ReceiverKeyID). + Int("spec_version", sharedKey.SpecVersion). Msg("Fetched group shared key") if _, _, err := lc.ensurePeerKey(ctx, sharedKey.Creator); err != nil { diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index d5031282..050402de 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -685,6 +685,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat lineMsg.Text = plainTextBody } else { lineMsg.Chunks = chunks + contentMetadata["e2eeVersion"] = strconv.Itoa(e2ee.ChunksVersion(chunks)) } var relatedMsg *database.Message @@ -776,6 +777,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, wrapConvertError(fmt.Errorf("failed to encrypt zipped file message: %w", err)) } lineMsg.Chunks = chunks + contentMetadata["e2eeVersion"] = strconv.Itoa(e2ee.ChunksVersion(chunks)) } retryReqSeq := int(time.Now().UnixMilli() % 1_000_000_000) @@ -801,6 +803,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } if err == nil { lineMsg.Chunks = chunks + contentMetadata["e2eeVersion"] = strconv.Itoa(e2ee.ChunksVersion(chunks)) lineMsg.Text = "" } } diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index e146dd7e..634cb295 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -51,6 +51,8 @@ type Manager struct { groupSessionChannels map[string]map[int]map[int]int latestGroupKey map[string]int // chatMid -> latest groupKeyId + // groupSpecVersion maps chatMid -> specVersion of the latest group key + groupSpecVersion map[string]int groupKeyLRU []groupKeyRef groupNativeObjects int @@ -83,6 +85,7 @@ func NewManagerWithRunner(r *gen.Runner) *Manager { groupKeys: make(map[string]map[int]int), groupSessionChannels: make(map[string]map[int]map[int]int), latestGroupKey: make(map[string]int), + groupSpecVersion: make(map[string]int), } } @@ -104,6 +107,7 @@ func (m *Manager) Close() { m.groupKeys = nil m.groupSessionChannels = nil m.latestGroupKey = nil + m.groupSpecVersion = nil m.groupKeyLRU = nil m.groupNativeObjects = 0 } @@ -540,6 +544,18 @@ func assembleCipherV1(chunks []string) ([]byte, error) { return append(append(b0, b1...), b2...), nil } +// ChunksVersion returns the Letter Sealing version (1 or 2) of encrypted message chunks. +func ChunksVersion(chunks []string) int { + if len(chunks) == 0 { + return 2 + } + ver, err := cipherVersion(chunks) + if err != nil { + return 2 + } + return ver +} + func cipherVersion(chunks []string) (int, error) { first, err := base64.StdEncoding.DecodeString(strings.TrimSpace(chunks[0])) if err != nil { @@ -561,6 +577,7 @@ func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroup ref := groupKeyRef{chatMid, sharedKey.GroupKeyID} if id := m.groupKeys[chatMid][sharedKey.GroupKeyID]; id != 0 { m.latestGroupKey[chatMid] = sharedKey.GroupKeyID + m.groupSpecVersion[chatMid] = sharedKey.SpecVersion m.touchGroupKeyLocked(ref) return id, nil } @@ -599,6 +616,7 @@ func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroup m.groupKeys[chatMid][sharedKey.GroupKeyID] = unwrappedKeyID m.latestGroupKey[chatMid] = sharedKey.GroupKeyID + m.groupSpecVersion[chatMid] = sharedKey.SpecVersion m.groupNativeObjects++ m.touchGroupKeyLocked(ref) @@ -631,6 +649,7 @@ func (m *Manager) evictGroupKeysLocked() error { } if m.latestGroupKey[ref.chatMid] == ref.groupKeyID { delete(m.latestGroupKey, ref.chatMid) + delete(m.groupSpecVersion, ref.chatMid) } if err := m.runner.KeyDestroy(keyID); err != nil && isFatalLTSMError(err) { return err @@ -860,6 +879,10 @@ func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType in return nil, err } + if m.groupSpecVersion[chatMid] == 1 { + return m.encryptV1Chunks(chanID, myKeyID, groupKeyID, payload) + } + ctB64, err := m.runner.ChannelEncryptV2(chanID, chatMid, fromMid, myKeyID, groupKeyID, contentType, seq, string(payload)) if err != nil { if isFatalLTSMError(err) {