Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sender-key-index-observer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": patch
---

Media frame cryptors now follow the key provider's current key index, matching the JavaScript and Swift SDKs. `BaseKeyProvider` implements the new `KeyIndexObservable` contract: installing a shared key moves the current index, `setCurrentKeyIndex` re-points live senders, and `getLatestKeyIndex` returns the current index. Custom `KeyProvider` implementations can opt into live re-pointing by implementing `KeyIndexObservable`. Behavior change: in non-shared key mode `setKey` no longer moves the sender index — the key may belong to a remote participant — use `setCurrentKeyIndex` to switch the local one.
1 change: 1 addition & 0 deletions livekit-android-sdk/detekt-baseline-release.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<ID>TooManyFunctions:CommunicationWorkaround.kt$CommunicationWorkaroundImpl : CommunicationWorkaround</ID>
<ID>TooManyFunctions:E2EEManager.kt$E2EEManager</ID>
<ID>TooManyFunctions:IncomingDataStreamManager.kt$IncomingDataStreamManagerImpl : IncomingDataStreamManager</ID>
<ID>TooManyFunctions:KeyProvider.kt$BaseKeyProvider : KeyProviderKeyIndexObservable</ID>
<ID>TooManyFunctions:LKLog.kt$LKLog$Companion</ID>
<ID>TooManyFunctions:LocalParticipant.kt$LocalParticipant : ParticipantOutgoingDataStreamManagerRpcManager</ID>
<ID>TooManyFunctions:LocalVideoTrack.kt$LocalVideoTrack : VideoTrack</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import io.livekit.android.room.track.RemoteVideoTrack
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.TrackPublication
import io.livekit.android.util.LKLog
import io.livekit.android.webrtc.peerconnection.RTCThreadToken
import io.livekit.android.webrtc.peerconnection.executeOnRTCThread
import livekit.LivekitModels.Encryption
import livekit.org.webrtc.FrameCryptor
import livekit.org.webrtc.FrameCryptor.FrameCryptionState
Expand All @@ -40,16 +42,18 @@ import livekit.org.webrtc.FrameCryptorFactory
import livekit.org.webrtc.PeerConnectionFactory
import livekit.org.webrtc.RtpReceiver
import livekit.org.webrtc.RtpSender
import java.util.Collections

class E2EEManager
@AssistedInject
constructor(
@Assisted val keyProvider: KeyProvider,
val peerConnectionFactory: PeerConnectionFactory,
dataPacketCryptorManagerFactory: DataPacketCryptorManager.Factory,
private val rtcThreadToken: RTCThreadToken,
) {
private var room: Room? = null
private val frameCryptors = mutableMapOf<Pair<String, Participant.Identity>, FrameCryptor>()
private val frameCryptors = Collections.synchronizedMap(mutableMapOf<Pair<String, Participant.Identity>, FrameCryptor>())
private var algorithm: FrameCryptorAlgorithm = FrameCryptorAlgorithm.AES_GCM
private lateinit var emitEvent: (roomEvent: RoomEvent) -> Unit?

Expand All @@ -58,9 +62,11 @@ constructor(
var enabled: Boolean = false
set(value) {
field = value
for (item in frameCryptors.entries) {
val frameCryptor = item.value
frameCryptor.isEnabled = enabled
synchronized(frameCryptors) {
for (item in frameCryptors.entries) {
val frameCryptor = item.value
frameCryptor.isEnabled = enabled
}
}
}

Expand Down Expand Up @@ -96,6 +102,7 @@ constructor(
this.enabled = true
this.room = room
this.emitEvent = emitEvent
observeKeyIndex()
this.room?.localParticipant?.trackPublications?.forEach { item ->
val participant = this.room!!.localParticipant
val publication = item.value
Expand Down Expand Up @@ -242,14 +249,42 @@ constructor(
LKLog.d { "ratchetSharedKey: newKey: $newKey" }
}

/**
* Re-stamps the frame cryptors of every track the local participant has
* already published with the provider's latest key index. Runs on the
* RTC thread like all other frame cryptor calls.
*/
internal fun setSenderKeyIndex(keyIndex: Int) {
val localIdentity = room?.localParticipant?.identity ?: return
executeOnRTCThread(rtcThreadToken) {
synchronized(frameCryptors) {
for ((key, frameCryptor) in frameCryptors) {
if (key.second == localIdentity) {
frameCryptor.keyIndex = keyIndex
}
}
}
}
}

private fun observeKeyIndex() {
(keyProvider as? KeyIndexObservable)?.addKeyIndexObserver(this) { index ->
setSenderKeyIndex(index)
}
}

internal fun cleanup() {
for (frameCryptor in frameCryptors.values) {
frameCryptor.dispose()
(keyProvider as? KeyIndexObservable)?.removeKeyIndexObserver(this)
synchronized(frameCryptors) {
for (frameCryptor in frameCryptors.values) {
frameCryptor.dispose()
}
frameCryptors.clear()
}
frameCryptors.clear()
}

internal fun dispose() {
(keyProvider as? KeyIndexObservable)?.removeKeyIndexObserver(this)
dataPacketCryptorManager.dispose()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,34 @@ interface KeyProvider {
var enableSharedKey: Boolean
}

/**
* Implemented by [KeyProvider]s that track the key index the local
* participant encrypts with and can push changes to observers.
*
* [E2EEManager] subscribes so the frame cryptors of media tracks that are
* already published get re-stamped whenever the index changes. Receivers
* are unaffected: they read the index from each incoming frame.
*/
interface KeyIndexObservable {

/**
* The key index the local participant encrypts with.
*/
val currentKeyIndex: Int

/**
* Registers [observer] under [owner]; [removeKeyIndexObserver] with the
* same owner detaches it. The observer is invoked with the latest index
* on every change and may be called from any thread.
*/
fun addKeyIndexObserver(owner: Any, observer: (keyIndex: Int) -> Unit)

/**
* Detaches the observer previously registered under [owner].
*/
fun removeKeyIndexObserver(owner: Any)
}

class BaseKeyProvider(
ratchetSalt: String = defaultRatchetSalt,
uncryptedMagicBytes: String = defaultMagicBytes,
Expand All @@ -51,9 +79,18 @@ class BaseKeyProvider(
keyRingSize: Int = defaultKeyRingSize,
discardFrameWhenCryptorNotReady: Boolean = defaultDiscardFrameWhenCryptorNotReady,
keyDerivationAlgorithm: FrameCryptorKeyDerivationAlgorithm = defaultKeyDerivationAlgorithm,
) : KeyProvider {
) : KeyProvider, KeyIndexObservable {

private val keyRingSize: Int = keyRingSize
private val keyIndexStateLock = Any()
private val keyIndexDispatchLock = Any()
private val keyIndexObservers = mutableMapOf<Any, (Int) -> Unit>()

@Volatile
private var _currentKeyIndex: Int = 0

private val latestSetIndex = mutableMapOf<String, Int>()
override val currentKeyIndex: Int
get() = _currentKeyIndex

override val rtcKeyProvider: FrameCryptorKeyProvider = FrameCryptorFactory.createFrameCryptorKeyProvider(
enableSharedKey,
Expand All @@ -67,7 +104,11 @@ class BaseKeyProvider(
)

override fun setSharedKey(key: String, keyIndex: Int?): Boolean {
return rtcKeyProvider.setSharedKey(keyIndex ?: 0, key.toByteArray())
return rtcKeyProvider.setSharedKey(keyIndex ?: 0, key.toByteArray()).also { succeeded ->
if (succeeded) {
setCurrentKeyIndex(keyIndex ?: 0)
}
}
}

override fun ratchetSharedKey(keyIndex: Int?): ByteArray {
Expand All @@ -94,10 +135,7 @@ class BaseKeyProvider(
return
}

val keyIndex = keyIndex ?: 0
latestSetIndex[participantId] = keyIndex

rtcKeyProvider.setKey(participantId, keyIndex, key.toByteArray())
rtcKeyProvider.setKey(participantId, keyIndex ?: 0, key.toByteArray())
}

override fun ratchetKey(participantId: String, keyIndex: Int?): ByteArray {
Expand All @@ -112,7 +150,42 @@ class BaseKeyProvider(
rtcKeyProvider.setSifTrailer(trailer)
}

/**
* Switches the local participant's encryption to the key at [keyIndex]
* (modulo the key ring size).
*
* Takes effect immediately for the media tracks already published and
* 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.

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.

keyIndexObservers.values.toList()
}
// Dispatch is serialized and each pass re-reads the newest index,
// so overlapping rotations can't leave senders on a stale index.
synchronized(keyIndexDispatchLock) {
val latest = _currentKeyIndex
observers.forEach { it(latest) }
}
}

/**
* The key index outgoing media frames and data packets are stamped with.
* In non-shared key mode [setKey] does not move it — the key may belong
* to a remote participant — so switch the local one with
* [setCurrentKeyIndex].
*/
override fun getLatestKeyIndex(participantId: String): Int {
return latestSetIndex[participantId] ?: 0
return currentKeyIndex
}

override fun addKeyIndexObserver(owner: Any, observer: (keyIndex: Int) -> Unit) {
synchronized(keyIndexStateLock) { keyIndexObservers[owner] = observer }
}

override fun removeKeyIndexObserver(owner: Any) {
synchronized(keyIndexStateLock) { keyIndexObservers.remove(owner) }
}
}
Loading