Skip to content

fix: start new E2EE frame cryptors at the shared key index - #1246

Merged
hiroshihorie merged 5 commits into
mainfrom
hiroshi/e2ee-shared-key-index
Oct 27, 2026
Merged

hiroshihorie merged 5 commits into
mainfrom
hiroshi/e2ee-shared-key-index

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Summary

In shared-key E2EE mode, BaseKeyProvider.setSharedKey(key, keyIndex: n) passed n to the native key ring but never recorded it. getLatestIndex() only read the per-participant map, so in shared-key mode it always returned 0. Two things read that value:

  • new sender frame cryptors (E2EEManager._addRtpSender)
  • encrypted data packets (encryptData)

So an app that keeps its shared key at a non-zero index got tracks whose sender cryptor pointed at slot 0. Epoch-based rotation with epoch % keyRingSize is one example. What happened next depended on what slot 0 held:

  • Empty: the sender dropped every frame with missingKey.
  • An older key: receivers could not decrypt.

Either way the publication looked healthy and the app saw no error. The only workaround was calling e2eeManager.setKeyIndex() after every publish, and that races the cryptor setup.

Change

  • setSharedKey now records the index.
  • In shared-key mode, getLatestIndex() returns that index for every participant.
  • The recorded index is the key ring slot the key lands in: the index modulo the ring size, with the ring clamped to 1 to 256 the way the native key ring does it. A key set at index 16 in a ring of 16 is in slot 0, so a new sender now starts at 0 there, not at 16, which is outside the ring. This applies to per-participant mode too.
  • A negative index no longer moves the recorded index. The key ring writes such a key into the current slot without moving it.
  • On web, the worker now applies the key index it receives for dataCryptorEncrypt. It used to ignore it and always encrypt data packets with the key in slot 0.

This matches the JS SDK, where the sender reads the key handler's current index. It also matches livekit/client-sdk-swift#1157, which starts new Swift sender cryptors at the key provider's current index.

Existing cryptors are not moved when setSharedKey changes the index. Apps still move live tracks with E2EEManager.setKeyIndex() as before. So a rotation that installs the new key first and switches later keeps working.

The branch also merges main, which brings the flutter_webrtc 1.6.3 pin the e2e harness needs.

Testing

  • test/e2ee/key_provider_test.dart covers the shared index, the ring wrap (16 and 19 in a ring of 16, 260 in a ring of 300 clamped to 256), the negative index and the per-participant wrap. Each case fails without the change it covers.
  • flutter test (487 passed, 1 skipped), flutter analyze, dart format and import_sorter all pass. The web worker compiles with dart compile js.
  • A new e2e-flutter scenario, E2EESharedKeyIndexTest (branch hiroshi/e2ee-shared-key-index-scenario in livekit/e2e-flutter), installs a shared key at index 3 with nothing in slot 0, publishes the camera, and checks the sender's key index and that the receiver decrypts. It repeats with index 21 in a ring of 16. On macOS against a local server:
    • this branch passes, as does the whole e2ee tag (3 of 3)
    • without the ring wrap commit, the second phase fails with the sender at index 21
    • on main, the first phase fails with the sender at index 0, and the receiver never decrypts because the sender drops every frame

Not covered yet:

  • The web data packet fix has not been run in a browser.
  • An E2EEManager-level unit test that a new sender cryptor receives the index. The e2e scenario covers it end to end.

In shared-key mode setSharedKey passed its index to the native key
ring but never recorded it, so getLatestIndex always returned 0. New
sender frame cryptors and encrypted data packets read that value, so
an app that keeps its key at another index (for example epoch based
rotation) published tracks that encrypted at an empty or stale slot.
The sender dropped every frame with a missing key error, or receivers
could not decrypt.

setSharedKey now records the index and getLatestIndex returns it for
every participant in shared-key mode, which matches the JS SDK.
Per-participant mode is unchanged.
@hiroshihorie
hiroshihorie marked this pull request as ready for review October 20, 2026 10:25
devin-ai-integration[bot]

This comment was marked as resolved.

The key ring stores a key at its index modulo the ring size, and the
native ring clamps its size to 1 to 256. getLatestIndex returned the raw
index, so a key set at an index past the ring size gave new frame
cryptors an index outside the ring. Their sender dropped every frame
with a missing key error even though the key was in the wrapped slot.

The recorded index is now the slot the key lands in, in shared and per
participant mode. A negative index replaces the key in the current
slot, so it no longer moves the recorded index either.
The web worker read the key index sent with dataCryptorEncrypt but
never applied it, so the data packet cryptor always encrypted with the
key in slot 0. With the shared key installed at another index, sending
encrypted data on web failed because slot 0 was empty. The worker now
sets the requested index on the data packet cryptor before encrypting,
which is what the native data packet cryptor does with its index
argument.
devin-ai-integration[bot]

This comment was marked as resolved.

Recompiled with `dart compile js web/e2ee.worker.dart -o
example/web/e2ee.worker.dart.js -m` so the example app's bundled worker
applies the requested key index to data packets. The rest of the diff
comes from the newer Dart SDK (3.13.1).
@hiroshihorie
hiroshihorie merged commit 8594479 into main Oct 27, 2026
15 checks passed
@hiroshihorie
hiroshihorie deleted the hiroshi/e2ee-shared-key-index branch October 27, 2026 06:29
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.

2 participants