Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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/nasty-kids-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": minor
---

End to end encryption for data channels option
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
webrtc = "137.7151.03"
webrtc = "137.7151.04"

androidJainSipRi = "1.3.0-91"
androidx-activity = "1.9.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import io.livekit.android.audio.AudioRecordSamplesDispatcher
import io.livekit.android.audio.CommunicationWorkaround
import io.livekit.android.audio.JavaAudioRecordPrewarmer
import io.livekit.android.audio.NoAudioRecordPrewarmer
import io.livekit.android.e2ee.DataPacketCryptorManager
import io.livekit.android.e2ee.DataPacketCryptorManagerImpl
import io.livekit.android.memory.CloseableManager
import io.livekit.android.util.LKLog
import io.livekit.android.util.LoggingLevel
Expand Down Expand Up @@ -373,6 +375,11 @@ internal object RTCModule {
}!!
}

@Provides
fun dataPacketCryptorManagerFactory(): DataPacketCryptorManager.Factory {
return DataPacketCryptorManagerImpl.Factory
}

@Provides
@Singleton
fun peerConnectionFactory(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright 2025 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.android.e2ee

import io.livekit.android.room.participant.Participant
import io.livekit.android.util.LKLog
import livekit.LivekitModels
import livekit.org.webrtc.DataPacketCryptor
import livekit.org.webrtc.DataPacketCryptorFactory
import livekit.org.webrtc.FrameCryptorAlgorithm

/**
* @suppress
*/
interface DataPacketCryptorManager {
fun encrypt(participantId: Participant.Identity, keyIndex: Int, payload: ByteArray): EncryptedPacket?
fun decrypt(participantId: Participant.Identity, packet: EncryptedPacket): ByteArray?
fun dispose()

interface Factory {
fun create(keyProvider: KeyProvider): DataPacketCryptorManager
}
}

/**
* @suppress
*/
class EncryptedPacket(
val payload: ByteArray,
val iv: ByteArray,
val keyIndex: Int,
)

/**
* @suppress
*/
fun LivekitModels.EncryptedPacket.toSdkType() =
EncryptedPacket(
payload = this.encryptedValue.toByteArray(),
iv = this.iv.toByteArray(),
keyIndex = this.keyIndex,
)

internal class DataPacketCryptorManagerImpl(
keyProvider: KeyProvider,
) : DataPacketCryptorManager {
var isDisposed = false
private val dataPacketCryptor: DataPacketCryptor = DataPacketCryptorFactory.createDataPacketCryptor(FrameCryptorAlgorithm.AES_GCM, keyProvider.rtcKeyProvider)

@Synchronized
override fun encrypt(participantId: Participant.Identity, keyIndex: Int, payload: ByteArray): EncryptedPacket? {
if (isDisposed) {
return null
}
val packet = dataPacketCryptor.encrypt(
participantId.value,
keyIndex,
payload,
)

if (packet == null) {
LKLog.i { "Error encrypting packet: null packet" }
return null
}

val payload = packet.payload
val iv = packet.iv
val keyIndex = packet.keyIndex

if (payload == null) {
LKLog.w { "Error encrypting packet: null payload" }
return null
}
if (iv == null) {
LKLog.i { "Error encrypting packet: null iv returned" }
return null
}

return EncryptedPacket(
payload = payload,
iv = iv,
keyIndex = keyIndex,
)
}

@Synchronized
override fun decrypt(participantId: Participant.Identity, packet: EncryptedPacket): ByteArray? {
if (isDisposed) {
return null
}
return dataPacketCryptor.decrypt(
participantId.value,
DataPacketCryptor.EncryptedPacket(
packet.payload,
packet.iv,
packet.keyIndex,
),
)
}

@Synchronized
override fun dispose() {
if (isDisposed) {
return
}
isDisposed = true
dataPacketCryptor.dispose()
}

object Factory : DataPacketCryptorManager.Factory {
override fun create(keyProvider: KeyProvider): DataPacketCryptorManager {
return DataPacketCryptorManagerImpl(keyProvider)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package io.livekit.android.e2ee
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.livekit.android.annotations.Beta
import io.livekit.android.events.RoomEvent
import io.livekit.android.room.Room
import io.livekit.android.room.participant.LocalParticipant
Expand All @@ -42,45 +43,59 @@ import livekit.org.webrtc.RtpSender
class E2EEManager
@AssistedInject
constructor(
@Assisted keyProvider: KeyProvider,
peerConnectionFactory: PeerConnectionFactory,
@Assisted val keyProvider: KeyProvider,
val peerConnectionFactory: PeerConnectionFactory,
dataPacketCryptorManagerFactory: DataPacketCryptorManager.Factory,
) {
private var room: Room? = null
private var keyProvider: KeyProvider
private var peerConnectionFactory: PeerConnectionFactory
private var frameCryptors = mutableMapOf<Pair<String, Participant.Identity>, FrameCryptor>()
private var algorithm: FrameCryptorAlgorithm = FrameCryptorAlgorithm.AES_GCM
private lateinit var emitEvent: (roomEvent: RoomEvent) -> Unit?

internal var dataPacketCryptorManager: DataPacketCryptorManager = dataPacketCryptorManagerFactory.create(keyProvider)

var enabled: Boolean = false
set(value) {
field = value
for (item in frameCryptors.entries) {
val frameCryptor = item.value
frameCryptor.isEnabled = enabled
}
}

init {
this.keyProvider = keyProvider
this.peerConnectionFactory = peerConnectionFactory
/**
* Enables data channel encryption. Decryption is always enabled for forward compatibility.
*/
@Beta
var dataChannelEncryptionEnabled = false

fun isDataChannelEncryptionEnabled(): Boolean {
return enabled && dataChannelEncryptionEnabled
}

fun keyProvider(): KeyProvider {
return this.keyProvider
}

suspend fun setup(room: Room, emitEvent: (roomEvent: RoomEvent) -> Unit) {
if (this.room != room) {
fun setup(room: Room, emitEvent: (roomEvent: RoomEvent) -> Unit) {
if (this.room != room && this.room != null) {
// E2EEManager already setup, clean up first
cleanUp()
cleanup()
}
this.enabled = true
this.room = room
this.emitEvent = emitEvent
this.room?.localParticipant?.trackPublications?.forEach { item ->
var participant = this.room!!.localParticipant
var publication = item.value
val participant = this.room!!.localParticipant
val publication = item.value
if (publication.track != null) {
addPublishedTrack(publication.track!!, publication, participant, room)
}
}
this.room?.remoteParticipants?.forEach { item ->
var participant = item.value
val participant = item.value
participant.trackPublications.forEach { item ->
var publication = item.value
val publication = item.value
if (publication.track != null) {
addSubscribedTrack(publication.track!!, publication, participant, room)
}
Expand All @@ -89,14 +104,14 @@ constructor(
}

fun addSubscribedTrack(track: Track, publication: TrackPublication, participant: RemoteParticipant, room: Room) {
var rtpReceiver: RtpReceiver? = when (publication.track!!) {
val rtpReceiver: RtpReceiver? = when (publication.track!!) {
is RemoteAudioTrack -> (publication.track!! as RemoteAudioTrack).receiver
is RemoteVideoTrack -> (publication.track!! as RemoteVideoTrack).receiver
else -> {
throw IllegalArgumentException("unsupported track type")
}
}
var frameCryptor = addRtpReceiver(rtpReceiver!!, participant.identity!!, publication.sid, publication.track!!.kind.name.lowercase())
val frameCryptor = addRtpReceiver(rtpReceiver!!, participant.identity!!, publication.sid, publication.track!!.kind.name.lowercase())
frameCryptor.setObserver { trackId, state ->
LKLog.i { "Receiver::onFrameCryptionStateChanged: $trackId, state: $state" }
emitEvent(
Expand All @@ -112,9 +127,9 @@ constructor(
}

fun removeSubscribedTrack(track: Track, publication: TrackPublication, participant: RemoteParticipant, room: Room) {
var trackId = publication.sid
var participantId = participant.identity
var frameCryptor = frameCryptors.get(trackId to participantId)
val trackId = publication.sid
val participantId = participant.identity
val frameCryptor = frameCryptors.get(trackId to participantId)
if (frameCryptor != null) {
frameCryptor.isEnabled = false
frameCryptor.dispose()
Expand All @@ -123,20 +138,20 @@ constructor(
}

fun addPublishedTrack(track: Track, publication: TrackPublication, participant: LocalParticipant, room: Room) {
var rtpSender: RtpSender? = when (publication.track!!) {
val rtpSender: RtpSender? = when (publication.track!!) {
is LocalAudioTrack -> (publication.track!! as LocalAudioTrack)?.sender
is LocalVideoTrack -> (publication.track!! as LocalVideoTrack)?.sender
else -> {
throw IllegalArgumentException("unsupported track type")
}
} ?: throw IllegalArgumentException("rtpSender is null")

var frameCryptor = addRtpSender(rtpSender!!, participant.identity!!, publication.sid, publication.track!!.kind.name.lowercase())
val frameCryptor = addRtpSender(rtpSender!!, participant.identity!!, publication.sid, publication.track!!.kind.name.lowercase())
frameCryptor.setObserver { trackId, state ->
LKLog.i { "Sender::onFrameCryptionStateChanged: $trackId, state: $state" }
emitEvent(
RoomEvent.TrackE2EEStateEvent(
room!!,
room,
publication.track!!,
publication,
participant,
Expand All @@ -147,9 +162,9 @@ constructor(
}

fun removePublishedTrack(track: Track, publication: TrackPublication, participant: LocalParticipant, room: Room) {
var trackId = publication.sid
var participantId = participant.identity
var frameCryptor = frameCryptors.get(trackId to participantId)
val trackId = publication.sid
val participantId = participant.identity
val frameCryptor = frameCryptors.get(trackId to participantId)
if (frameCryptor != null) {
frameCryptor.isEnabled = false
frameCryptor.dispose()
Expand All @@ -171,7 +186,7 @@ constructor(
}

private fun addRtpSender(sender: RtpSender, participantId: Participant.Identity, trackId: String, kind: String): FrameCryptor {
var frameCryptor = FrameCryptorFactory.createFrameCryptorForRtpSender(
val frameCryptor = FrameCryptorFactory.createFrameCryptorForRtpSender(
peerConnectionFactory,
sender,
participantId.value,
Expand All @@ -180,12 +195,13 @@ constructor(
)

frameCryptors[trackId to participantId] = frameCryptor
frameCryptor.setEnabled(enabled)
frameCryptor.isEnabled = enabled
frameCryptor.keyIndex = keyProvider.getLatestKeyIndex(participantId.value)
return frameCryptor
}

private fun addRtpReceiver(receiver: RtpReceiver, participantId: Participant.Identity, trackId: String, kind: String): FrameCryptor {
var frameCryptor = FrameCryptorFactory.createFrameCryptorForRtpReceiver(
val frameCryptor = FrameCryptorFactory.createFrameCryptorForRtpReceiver(
peerConnectionFactory,
receiver,
participantId.value,
Expand All @@ -194,7 +210,8 @@ constructor(
)

frameCryptors[trackId to participantId] = frameCryptor
frameCryptor.setEnabled(enabled)
frameCryptor.isEnabled = enabled
frameCryptor.keyIndex = keyProvider.getLatestKeyIndex(participantId.value)
return frameCryptor
}

Expand All @@ -204,27 +221,43 @@ constructor(
*/
fun enableE2EE(enabled: Boolean) {
this.enabled = enabled
for (item in frameCryptors.entries) {
var frameCryptor = item.value
frameCryptor.setEnabled(enabled)
}
}

/**
* Ratchet key for local participant
*/
fun ratchetKey() {
var newKey = keyProvider.ratchetSharedKey()
val newKey = keyProvider.ratchetSharedKey()
LKLog.d { "ratchetSharedKey: newKey: $newKey" }
}

fun cleanUp() {
internal fun cleanup() {
for (frameCryptor in frameCryptors.values) {
frameCryptor.dispose()
}
frameCryptors.clear()
}

internal fun dispose() {
dataPacketCryptorManager.dispose()
}

fun encrypt(byteArray: ByteArray): EncryptedPacket? {
val participantId = room?.localParticipant?.identity ?: Participant.Identity("")
return dataPacketCryptorManager.encrypt(
participantId,
keyIndex = keyProvider.getLatestKeyIndex(participantId.value),
payload = byteArray,
)
}

fun decrypt(participantId: Participant.Identity, packet: EncryptedPacket): ByteArray? {
return dataPacketCryptorManager.decrypt(
participantId = participantId,
packet = packet,
)
}

@AssistedFactory
interface Factory {
fun create(
Expand Down
Loading