From 004cd1a39a98261f525d36a53654fec0357c864a Mon Sep 17 00:00:00 2001 From: highesttt Date: Mon, 5 Oct 2026 12:37:12 -0400 Subject: [PATCH] fix: bound native group keys and channels LTSM has a fixed 16 MiB heap and the manager kept the native key and channels of every group key it had ever unwrapped. A session that saw enough distinct group keys exhausted the heap and aborted with "ltsm: WASM abort called". Keep at most 4096 native group keys and channels, evicting the least recently used. Eviction destroys the native key and every channel created from it. The connector already fetches and unwraps a missing group key on demand, so an evicted key is reloaded the next time it is needed. --- pkg/connector/send_message.go | 9 ++++ pkg/e2ee/manager.go | 77 +++++++++++++++++++++++++++-------- pkg/ltsm/runtime.go | 8 ++++ pkg/runner.go | 35 ++++++++++++++++ 4 files changed, 113 insertions(+), 16 deletions(-) diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index fd54205..ef2dc55 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -747,6 +747,15 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat if isGroup { chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, zipPayload) + if err != nil && !errors.Is(err, ltsm.ErrAbort) { + if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch == nil { + chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, zipPayload) + } else if errors.Is(errFetch, ltsm.ErrAbort) { + return nil, errFetch + } else if errFetch = lineGroupE2EEFetchFailureError(errFetch); errFetch != nil { + return nil, errFetch + } + } } else { myRaw, myKeyID, errKey := lc.E2EE.MyKeyIDs() if errKey != nil { diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 42d62b6..b852743 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -49,8 +49,19 @@ type Manager struct { groupSessionChannels map[string]map[int]map[int]int latestGroupKey map[string]int // chatMid -> latest groupKeyId + + groupKeyLRU []groupKeyRef + groupNativeObjects int +} + +type groupKeyRef struct { + chatMid string + groupKeyID int } +// LTSM's heap is a fixed 16 MiB, so the native objects kept for group chats are bounded. +const maxGroupNativeObjects = 4096 + func NewManager() (*Manager, error) { r, err := gen.GetRunner() if err != nil { @@ -473,8 +484,10 @@ func cipherVersion(chunks []string) (int, error) { func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroupSharedKey) (int, error) { m.mu.Lock() defer m.mu.Unlock() + ref := groupKeyRef{chatMid, sharedKey.GroupKeyID} if id := m.groupKeys[chatMid][sharedKey.GroupKeyID]; id != 0 { m.latestGroupKey[chatMid] = sharedKey.GroupKeyID + m.touchGroupKeyLocked(ref) return id, nil } @@ -512,8 +525,44 @@ func (m *Manager) UnwrapGroupSharedKey(chatMid string, sharedKey *line.E2EEGroup m.groupKeys[chatMid][sharedKey.GroupKeyID] = unwrappedKeyID m.latestGroupKey[chatMid] = sharedKey.GroupKeyID + m.groupNativeObjects++ + m.touchGroupKeyLocked(ref) + + return unwrappedKeyID, m.evictGroupKeysLocked() +} + +func (m *Manager) touchGroupKeyLocked(ref groupKeyRef) { + for i := len(m.groupKeyLRU) - 1; i >= 0; i-- { + if m.groupKeyLRU[i] == ref { + m.groupKeyLRU = append(m.groupKeyLRU[:i], m.groupKeyLRU[i+1:]...) + break + } + } + m.groupKeyLRU = append(m.groupKeyLRU, ref) +} - return unwrappedKeyID, nil +func (m *Manager) evictGroupKeysLocked() error { + for m.groupNativeObjects > maxGroupNativeObjects && len(m.groupKeyLRU) > 1 { + ref := m.groupKeyLRU[0] + m.groupKeyLRU = m.groupKeyLRU[1:] + keyID := m.groupKeys[ref.chatMid][ref.groupKeyID] + m.groupNativeObjects -= 1 + len(m.groupSessionChannels[ref.chatMid][ref.groupKeyID]) + delete(m.groupKeys[ref.chatMid], ref.groupKeyID) + if len(m.groupKeys[ref.chatMid]) == 0 { + delete(m.groupKeys, ref.chatMid) + } + delete(m.groupSessionChannels[ref.chatMid], ref.groupKeyID) + if len(m.groupSessionChannels[ref.chatMid]) == 0 { + delete(m.groupSessionChannels, ref.chatMid) + } + if m.latestGroupKey[ref.chatMid] == ref.groupKeyID { + delete(m.latestGroupKey, ref.chatMid) + } + if err := m.runner.KeyDestroy(keyID); err != nil && isFatalLTSMError(err) { + return err + } + } + return nil } // GenerateGroupKey generates a new Curve25519 key in the WASM module for use as a group key. @@ -571,16 +620,12 @@ func (m *Manager) DecryptGroupMessage(msg *line.Message, chatMid string) (string } m.mu.Lock() - keyMap, ok := m.groupKeys[chatMid] - var unwrappedKeyID int - if ok { - unwrappedKeyID = keyMap[groupKeyID] - } - m.mu.Unlock() - + defer m.mu.Unlock() + unwrappedKeyID := m.groupKeys[chatMid][groupKeyID] if unwrappedKeyID == 0 { return "", groupKeyID, fmt.Errorf("group key %d not found", groupKeyID) } + m.touchGroupKeyLocked(groupKeyRef{chatMid, groupKeyID}) ver, err := cipherVersion(msg.Chunks) if err != nil { @@ -595,7 +640,7 @@ func (m *Manager) DecryptGroupMessage(msg *line.Message, chatMid string) (string if err != nil { return "", groupKeyID, err } - chanID, err := m.ensureGroupChannel(chatMid, groupKeyID, unwrappedKeyID, senderKeyID) + chanID, err := m.ensureGroupChannelLocked(chatMid, groupKeyID, unwrappedKeyID, senderKeyID) if err != nil { return "", groupKeyID, err } @@ -628,10 +673,8 @@ func (m *Manager) DecryptGroupMessage(msg *line.Message, chatMid string) (string return pt, groupKeyID, err } -func (m *Manager) ensureGroupChannel(chatMid string, groupKeyID, unwrappedKeyID, senderKeyID int) (int, error) { - m.mu.Lock() - defer m.mu.Unlock() - +// Callers keep m.mu held while using the channel so an eviction cannot destroy it. +func (m *Manager) ensureGroupChannelLocked(chatMid string, groupKeyID, unwrappedKeyID, senderKeyID int) (int, error) { if m.groupSessionChannels[chatMid] == nil { m.groupSessionChannels[chatMid] = make(map[int]map[int]int) } @@ -653,7 +696,8 @@ func (m *Manager) ensureGroupChannel(chatMid string, groupKeyID, unwrappedKeyID, } m.groupSessionChannels[chatMid][groupKeyID][senderKeyID] = chanID - return chanID, nil + m.groupNativeObjects++ + return chanID, m.evictGroupKeysLocked() } func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) ([]string, error) { @@ -668,6 +712,7 @@ func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) // Falls back to V1 if V2 fails (some clients like iOS still use V1). func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType int, payload []byte) ([]string, error) { m.mu.Lock() + defer m.mu.Unlock() groupKeyID, ok := m.latestGroupKey[chatMid] var unwrappedKeyID int if ok { @@ -676,13 +721,13 @@ func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType in myKeyID := m.myRawKeyID seq := m.sequence[chatMid] + 1 m.sequence[chatMid] = seq - m.mu.Unlock() if !ok || unwrappedKeyID == 0 { return nil, fmt.Errorf("%w for group %s", ErrGroupKeyNotLoaded, chatMid) } + m.touchGroupKeyLocked(groupKeyRef{chatMid, groupKeyID}) - chanID, err := m.ensureGroupChannel(chatMid, groupKeyID, unwrappedKeyID, myKeyID) + chanID, err := m.ensureGroupChannelLocked(chatMid, groupKeyID, unwrappedKeyID, myKeyID) if err != nil { return nil, err } diff --git a/pkg/ltsm/runtime.go b/pkg/ltsm/runtime.go index efbbc37..6894db0 100644 --- a/pkg/ltsm/runtime.go +++ b/pkg/ltsm/runtime.go @@ -335,6 +335,14 @@ func (rt *Runtime) E2EEKeyGetKeyId(keyPtr uint32) (int, error) { return int(int32(result)), nil } +func (rt *Runtime) E2EEKeyDestroy(ptr uint32) error { + return rt.imp.Destroy("E2EEKey", ptr) +} + +func (rt *Runtime) E2EEChannelDestroy(ptr uint32) error { + return rt.imp.Destroy("E2EEChannel", ptr) +} + func (rt *Runtime) E2EEKeyGetPublicKey(keyPtr uint32) ([]byte, error) { return rt.Curve25519KeyGetPublicKey(keyPtr) } diff --git a/pkg/runner.go b/pkg/runner.go index d806929..d50ad7e 100644 --- a/pkg/runner.go +++ b/pkg/runner.go @@ -190,6 +190,15 @@ func (r *Runner) unwrapGroupSharedKeyPanicSafe(chanPtr uint32, encKey []byte) (k return r.rt.E2EEChannelUnwrapGroupSharedKey(chanPtr, encKey) } +func (r *Runner) destroyPanicSafe(class string, destroy func(uint32) error, ptr uint32) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = ltsmPanicError("ltsm "+class+" destroy", recovered) + } + }() + return destroy(ptr) +} + func (r *Runner) encryptV1PanicSafe(chanPtr uint32, plaintext []byte) (ciphertext []byte, err error) { defer func() { if recovered := recover(); recovered != nil { @@ -664,6 +673,32 @@ func (r *Runner) ChannelUnwrapGroupSharedKey(channelID int, encryptedSharedKeyB6 return r.putKey(keyPtr), nil } +// KeyDestroy releases an unwrapped group key and every channel created from it. +func (r *Runner) KeyDestroy(keyID int) error { + r.mu.Lock() + defer r.mu.Unlock() + + keyPtr, err := r.getKey(keyID) + if err != nil { + return err + } + var errs []error + for cacheKey, channelID := range r.channelsByKey { + if cacheKey.keyID != keyID { + continue + } + chanPtr := r.channelStore[channelID] + delete(r.channelsByKey, cacheKey) + delete(r.channelStore, channelID) + delete(r.goChannels, channelID) + errs = append(errs, r.destroyPanicSafe("E2EEChannel", r.rt.E2EEChannelDestroy, chanPtr)) + } + delete(r.keyStore, keyID) + delete(r.goKeys, keyID) + errs = append(errs, r.destroyPanicSafe("E2EEKey", r.rt.E2EEKeyDestroy, keyPtr)) + return errors.Join(errs...) +} + // ChannelEncryptV1 encrypts plaintext with channel V1 (AES-256-CBC + MAC). // Uses pure Go crypto when a Go channel is available. func (r *Runner) ChannelEncryptV1(channelID int, plaintext string) (string, error) {