fix(e2ee): fetch the group shared key the message was encrypted with - #233
Merged
EdamAme-x merged 2 commits intoSep 6, 2026
Merged
Conversation
LINE rotates a group's shared key whenever the membership changes, so the
groupKeyId carried in a message envelope names the generation the message
was encrypted under. The group branch of getE2EELocalPublicKey ignored it:
on a mismatch it dropped the cached key and called getLastE2EEGroupSharedKey,
which answers with the current generation. Every message from an earlier
generation was then decrypted with the wrong key and failed the AES-GCM tag
check ("Unsupported state or unable to authenticate data"). The failures
cluster per group rather than per sender, and a group can be affected for
all of its history: the single cache slot `e2eeGroupKeys:${mid}` held one
key per group, so the two generations kept evicting each other and every
lookup went back to the server for the wrong one.
Ask for the requested generation with getE2EEGroupSharedKey, and cache per
generation under `e2eeGroupKeys:${mid}:${keyId}`. The unsuffixed slot is
still read, so storages written by earlier versions keep working, and still
written with the key used most recently, so anything else reading it
directly is unaffected. When no key id is requested (the encrypt path wants
whatever key is current) the last-key fetch and its NOT_FOUND ->
tryRegisterE2EEGroupKey fallback are unchanged, and a keyed fetch that comes
back NOT_FOUND falls back to the last key so no case gets worse than before.
The shared-key unwrap moves into a helper as it is now reached from two
places; it is otherwise unchanged.
(cherry picked from commit 0febc75)
The group key id is normalised with Number() so that a string form matches a cached numeric one, but a value that is not a number normalises to NaN. NaN never equals a cached key id, so the cache was always missed, and the keyed fetch then sent groupKeyId: NaN to the server — a value the previous code could not produce, since it never varied its request on the key id at all. Treat a non-finite id as "no id requested": log it and take the last-key path, which is what happened before the key id chose the generation. Also pin the two edge cases of that normalisation in the tests: key id 0 now takes the keyed path (the old truthiness test sent it to the last key) and still falls back when the server does not serve that generation. (cherry picked from commit 52c2f36)
There was a problem hiding this comment.
🟢 Approval recommended
The logic change is narrowly scoped, preserves backward compatibility via legacy cache handling, and is backed by thorough unit tests covering the key selection and caching edge cases.
Pull request overview
Fixes E2EE group-message decryption for messages encrypted under older generations of a group’s shared key by fetching the specific groupKeyId from the message envelope (when provided) and caching group keys per generation to avoid cache thrash.
Changes:
- Update
getE2EELocalPublicKey(group branch) to (a) normalize/validategroupKeyId, (b) fetch bygroupKeyIdviagetE2EEGroupSharedKey, and (c) fall back togetLastE2EEGroupSharedKeyonNOT_FOUND. - Introduce per-generation group-key caching (
e2eeGroupKeys:<mid>:<keyId>) while preserving read/write compatibility with the legacy unsuffixed cache key. - Add a new Deno test suite covering keyed vs last-key selection, generation
0, NaN/invalid ids, and multi-generation caching behavior.
File summaries
| File | Description |
|---|---|
| packages/linejs/base/e2ee/mod.ts | Fetch group shared keys by requested generation (groupKeyId) and cache per generation with legacy compatibility. |
| packages/linejs/base/e2ee/group_key_selection.test.ts | Adds focused unit coverage validating correct RPC selection, caching behavior, and edge cases (0/NOT_FOUND/invalid ids). |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Group E2EE messages that were sent under an earlier generation of the group's shared key can never be decrypted: they all fail the AES-GCM tag check with
Unsupported state or unable to authenticate data. LINE rotates a group's shared key on every membership change, so thegroupKeyIdcarried in a message envelope names the generation that message was encrypted under — and the group branch ofgetE2EELocalPublicKeyignores it.decryptE2EETextMessage(and the location and data variants,packages/linejs/base/e2ee/mod.ts:748,:831,:908) passes the envelope's key id down:but on the receiving side (
packages/linejs/base/e2ee/mod.ts:147-181) the id only ever invalidates the cache — it is never used to ask for that generation:So a mismatch is answered with the current key, which is exactly the key the message was not encrypted with, and the decrypt fails. Two things follow from that, and both match what is reported in #211:
e2eeGroupKeys:${mid}holds one key per group (mod.ts:237), so two generations in the same group keep evicting each other and every lookup goes back to the server for the wrong key. Clearing storage does not help; adding a member and re-triggering derivation just moves which generation is the lucky one.Fix
Ask for the generation that was requested, and cache per generation.
talk.getE2EEGroupSharedKey({ keyVersion: 2, chatMid, groupKeyId }). If the server answersNOT_FOUNDfor that generation, fall back to the last-key fetch, so no case ends up worse than before.mod.ts:550) — thegetLastE2EEGroupSharedKeycall and itsNOT_FOUND→tryRegisterE2EEGroupKeyfallback are unchanged.e2eeGroupKeys:${mid}:${keyId}. The unsuffixed slot is still read, so storages written by earlier versions keep working, and still written with the key used most recently, so anything reading it directly is unaffected. No migration.Number()so a string form matches a cached numeric one; anything non-finite would otherwise miss the cache forever and putgroupKeyId: NaNon the wire, a request the old code could never produce.The shared-key unwrap (ECDH against the creator key, then AES-256-CBC) moves into
unwrapE2EEGroupSharedKeyunchanged, because it is now reached from the keyed and the last-key path both. The cache lookup moves intogetCachedE2EEGroupKeyfor the same reason. Split into two commits so the NaN guard can be read on its own.This may fix #211 — the reported pattern (a group where other members' messages decrypt but the account's own do not, and a fresh group where nothing decrypts until another account derives the key first) is what a wrong-generation lookup looks like from the outside. It was verified on one account only, so it is not claimed as a full explanation of that issue; the self-key material discussed in Update 2 there could still be a second, independent problem.
Testing
New
packages/linejs/base/e2ee/group_key_selection.test.ts, 9 cases. The fixture wraps each generation's shared key exactly the waytryRegisterE2EEGroupKeydoes, so the ECDH and AES-256-CBC unwrap runs for real and the tests assert the actual key material that comes back, not just which RPC fired:getE2EEGroupSharedKey), not as the last key.mainthis returns the wrong key material for the older one.NaNis sent.cd packages/linejs && deno test -A— 225 passed, 0 failed (216 onmain@ 802f4c7).deno fmt --checkanddeno checkclean on both touched files.Beyond the unit tests, this was verified on a real account against groups whose key had rotated: messages that previously failed with
unable to authenticate datafor the whole group decrypt after the change, and the storage ends up holding both generations undere2eeGroupKeys:<mid>:<keyId>.This change is independent of the other pull requests open from this fork; it touches only
base/e2ee/mod.ts.