Skip to content

Follow key provider key index on media frame cryptors - #1042

Open
tangowhisky-dev wants to merge 1 commit into
livekit:mainfrom
tangowhisky-dev:e2ee-sender-key-index-observer
Open

tangowhisky-dev wants to merge 1 commit into
livekit:mainfrom
tangowhisky-dev:e2ee-sender-key-index-observer

Conversation

@tangowhisky-dev

Copy link
Copy Markdown

Supersedes #1039 — ports the approach merged in client-sdk-swift#1157 to Android.

Problem

addRtpSender stamps a sender's FrameCryptor.keyIndex once at creation and never updates it. Apps that rotate keys into different key-ring slots (e.g. per-epoch MLS key rings in E2EE deployments) can update live data packets via setSharedKey(key, index), but media senders keep stamping the old index — receivers hit MISSING_KEY and drop frames. There is no public API to re-point live senders.

Approach

Mirrors the Swift design rather than adding a setKeyIndex(participant) API (whose identity parameter let a remote identity redirect local encryption — the review concern on the earlier Swift PR #1156).

KeyIndexObservable — new opt-in contract for KeyProviders that track the local encryption index:

interface KeyIndexObservable {
    val currentKeyIndex: Int
    fun addKeyIndexObserver(owner: Any, observer: (keyIndex: Int) -> Unit)
    fun removeKeyIndexObserver(owner: Any)
}

BaseKeyProvider implements it — currentKeyIndex is the single source of truth:

  • setSharedKey(key, index) moves it (parity with JS SDK setSharedKey behavior merged in client-sdk-js#1830/#1831)
  • New setCurrentKeyIndex(index) exposes it directly (ring-size modulo)
  • getLatestKeyIndex() returns it — senders stamp at creation, data packets pick it up per-send
  • Observer dispatch is serialized and re-reads the latest index, so overlapping rotations can't leave senders stale

E2EEManager subscribes internally and re-stamps the frame cryptors of already-published local tracks on the RTC thread (via executeOnRTCThread, honoring the SDK's threading rule). Receivers are untouched — they read the index from each frame's trailer.

Behavior change

In non-shared key mode, setKey() no longer moves the sender index — the key may belong to a remote participant, so it must not redirect local encryption. Apps switching the local index should call setCurrentKeyIndex(). This matches the Swift change.

Custom KeyProvider implementations (e.g. ones that bypass BaseKeyProvider to hand raw key bytes to the RTC provider) opt into live re-pointing by implementing KeyIndexObservable.

Test plan

  • FrameCryptorKeyIndexTest (new, 7 tests): shared-key install moves index, setCurrentKeyIndex modulo wrap, non-shared setKey does not move index, observer add/remove, new senders stamp current index, existing senders follow changes, receivers not re-stamped
  • Existing e2ee tests pass (DataPacketCryptorMockE2ETest, DataTrackCryptorTest)
  • :livekit-android-sdk:detektRelease, spotlessCheck clean

Ports the semantics merged in livekit/client-sdk-swift#1157 to Android,
superseding the approach in livekit#1039.

BaseKeyProvider now implements the new KeyIndexObservable contract:
the current key index is the single source of truth for what the local
participant encrypts with. Installing a shared key moves the index and
setCurrentKeyIndex() exposes it directly; getLatestKeyIndex() returns
it for both media senders and data packets.

E2EEManager subscribes to the provider and re-stamps the frame cryptors
of already-published local tracks on the RTC thread whenever the index
changes, and stamps new senders at creation. Receivers are unaffected:
they read the index from each incoming frame. Cryptor map iteration is
synchronized, and a serialized dispatch that re-reads the latest index
prevents overlapping rotations from leaving senders stale.

Behavior change: in non-shared key mode setKey() no longer moves the
sender index, since the key may belong to a remote participant. Use
setCurrentKeyIndex() to switch the local one.

Custom KeyProvider implementations opt into live re-pointing by
implementing KeyIndexObservable.
@changeset-bot

changeset-bot Bot commented Oct 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 41fd624

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
client-sdk-android Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

*/
fun setCurrentKeyIndex(keyIndex: Int) {
val observers = synchronized(keyIndexStateLock) {
_currentKeyIndex = Math.floorMod(keyIndex, keyRingSize)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Key rotation crashes on older Android

On Android API 21–23, setCurrentKeyIndex calls Math.floorMod, which those devices lack. Even constructing E2EEOptions(sharedKey) can crash because it calls setSharedKey.

Learn more

The SDK supports Android API 21, but Math.floorMod is unavailable before API 24. E2EEOptions installs a shared key during construction, which reaches this call after a successful native key install. The resulting linkage error prevents E2EE setup on older devices.

Example: On an API 21 device, construct E2EEOptions(sharedKey = "secret"). Once the provider accepts that key, index normalization throws NoSuchMethodError instead of creating the options.

Recommended fix: Normalize the remainder using arithmetic supported on API 21, retaining nonnegative indices for negative inputs; verify initialization on an API 21 device or emulator.

Suggested change
_currentKeyIndex = Math.floorMod(keyIndex, keyRingSize)
val remainder = keyIndex % keyRingSize
_currentKeyIndex = if (remainder < 0) remainder + keyRingSize else remainder

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

* for every data packet sent afterwards. Receivers are unaffected: they
* read the index from each frame.
*/
fun setCurrentKeyIndex(keyIndex: Int) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Key rotation lacks required Room example

setCurrentKeyIndex adds a public rotation API without the required short example at the Room entry point. The same omission affects KeyIndexObservable.

Learn more

The repository's review rules require short code examples for new public APIs at the entry point, such as Room. This change exposes setCurrentKeyIndex and KeyIndexObservable without showing room users how to access the provider and rotate an existing sender.

Example: A consumer reading Room documentation has no example connecting room.e2eeManager?.keyProvider() with an index change for published media.

Recommended fix: Add a short example in the Room entry-point documentation that shows the supported key-provider rotation flow and how a custom provider can opt into KeyIndexObservable.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant