Skip to content

fix(e2ee): fetch the group shared key the message was encrypted with - #233

Merged
EdamAme-x merged 2 commits into
evex-dev:mainfrom
frankekn:upstream/group-shared-key-by-id
Sep 6, 2026
Merged

fix(e2ee): fetch the group shared key the message was encrypted with#233
EdamAme-x merged 2 commits into
evex-dev:mainfrom
frankekn:upstream/group-shared-key-by-id

Conversation

@frankekn

@frankekn frankekn commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 the groupKeyId carried in a message envelope names the generation that message was encrypted under — and the group branch of getE2EELocalPublicKey ignores it.

decryptE2EETextMessage (and the location and data variants, packages/linejs/base/e2ee/mod.ts:748, :831, :908) passes the envelope's key id down:

const groupK = await this.getE2EELocalPublicKey(to, receiverKeyId) as GroupKey;

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:

key = (await this.client.storage.get(`e2eeGroupKeys:${mid}`)) as string;
if (keyId && key) {
	const keyData = JSON.parse(key);
	if (keyId !== keyData["keyId"]) {
		this.e2eeLog("getE2EELocalPublicKeykeyIdMismatch", mid);
		key = undefined;                       // drop the cached key…
	} else {
		return keyData;
	}
}
if (!key) {
	e2eeGroupSharedKey = await this.client.talk.getLastE2EEGroupSharedKey({
		keyVersion: 2,
		chatMid: mid,                          // …and fetch the *current* one
	});

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:

  • The failures cluster per group, not per sender. Every message from an older generation fails, whoever sent it; a group whose key rotated once can fail for its entire history.
  • The single cache slot makes it permanent. 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.

  • When a key id is given, fetch with talk.getE2EEGroupSharedKey({ keyVersion: 2, chatMid, groupKeyId }). If the server answers NOT_FOUND for that generation, fall back to the last-key fetch, so no case ends up worse than before.
  • When no key id is given — the encrypt path, which legitimately wants whatever key is current (mod.ts:550) — the getLastE2EEGroupSharedKey call and its NOT_FOUNDtryRegisterE2EEGroupKey fallback are unchanged.
  • Cache 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 reading it directly is unaffected. No migration.
  • A key id that is not a number is treated as "no id requested". The id is normalised with Number() so a string form matches a cached numeric one; anything non-finite would otherwise miss the cache forever and put groupKeyId: NaN on 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 unwrapE2EEGroupSharedKey unchanged, because it is now reached from the keyed and the last-key path both. The cache lookup moves into getCachedE2EEGroupKey for 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 way tryRegisterE2EEGroupKey does, 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:

  1. A cached generation is returned with no RPC at all.
  2. The legacy unsuffixed slot is still honoured when its key id matches.
  3. A requested generation is fetched by id (getE2EEGroupSharedKey), not as the last key.
  4. An omitted key id still asks for the last key.
  5. Two generations of one group stay cached side by side — the case that used to thrash. On main this returns the wrong key material for the older one.
  6. Generation 0 is fetched by id like any other (the old truthiness test sent it to the last key).
  7. Generation 0 falls back to the last key when the server does not serve it.
  8. A key id that is not a number never reaches the wire — the last-key path is taken and no NaN is sent.
  9. A generation the server has dropped falls back to the last key.

cd packages/linejs && deno test -A — 225 passed, 0 failed (216 on main @ 802f4c7). deno fmt --check and deno check clean 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 data for the whole group decrypt after the change, and the storage ends up holding both generations under e2eeGroupKeys:<mid>:<keyId>.

This change is independent of the other pull requests open from this fork; it touches only base/e2ee/mod.ts.

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)
Copilot AI lite review requested due to automatic review settings September 5, 2026 16:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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/validate groupKeyId, (b) fetch by groupKeyId via getE2EEGroupSharedKey, and (c) fall back to getLastE2EEGroupSharedKey on NOT_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: E2EE group messages fail to decrypt for self-sent messages on one account (Unsupported state or unable to authenticate data)

3 participants