Skip to content

Commit 4294af4

Browse files
End-to-end encryption for call media (#1801)
* feat(core): end-to-end encryption for call media Adds framed AES-GCM E2EE, following the shape the JS and iOS SDKs use so the same integration works across platforms. An E2EEManager is attached to a Call before join. The publisher installs an encryptor on each outgoing sender after addTransceiver, and the subscriber installs a decryptor on each incoming receiver once it knows which user the track belongs to. The join request carries an e2ee flag that the coordinator validates against the call's encryption settings. Key generation and distribution stay out of the SDK, per spec. Integrators either drive StreamEncryptionManager's key APIs or supply their own E2EEManager, which detaches Stream from the encryption entirely. Notable decisions: - Key management lives on E2EEKeyProvider, separate from E2EEManager. The spec's manager contract is only encrypt/decrypt, and a custom manager backed by MLS or a hardware keystore has no key setters to offer. - Call.setE2EESharedKey and friends lazy-create the default manager, so setting a key is all it takes to enable encryption. A manager the SDK created is disposed on cleanup; one handed to us by the app is not, since it usually outlives the call. - If the encryptor cannot be attached, the publisher drops the transceiver instead of caching it. Publishing there would send plaintext on a call the app believes is encrypted. StreamEncryptionManager reaches org.webrtc.EncryptionManager through reflection, because no published WebRTC artifact carries GetStream/webrtc#110 yet: 146.7.0 (May) and 148.0.1-SNAPSHOT (Aug 12) both predate it. Compiling against the class directly would break every module. The binding resolves methods by name and arity, isSupported() reports whether the class exists, and the cost is nil since encrypt/decrypt run once per track attach rather than per frame. Replace it with direct calls when the AAR ships. Still open: the SFU's JoinResponse.e2ee_enabled from protocol#1892 is not in our vendored proto, so CallState.e2eeEnabled reflects the attached manager rather than the server's view. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(core): own E2EE keys on StreamEncryptionManager Drop Call-level key helpers and the JNI wrapper so the app holds the manager, sets keys before attach, and disposes it. Point WebRTC at the snapshot that ships EncryptionManager. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(core): send e2ee on first join and keep the lobby manager alive The coordinator rejects a join whose flag disagrees with the call. Rejoin and migrate omit the param and reuse the attached manager. The lobby no longer disposes that manager when Join clears the task. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(core): dump public API for E2EE types Co-authored-by: Cursor <cursoragent@cursor.com> * fix(e2ee): address media encryption review Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(e2ee): return results from manager setup Co-authored-by: Cursor <cursoragent@cursor.com> * Remove comment * fix(core): reattach decryptors after track removal and document E2EE events Join coordinator tests now match the e2ee joinRequest argument. Removed tracks drop decryptor tracking so a re-added receiver can be attached again, and the demo plus KDoc cover runtime manager events. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(core): trace E2EE setup and native encryption errors Encryption problems were visible only in local logcat, so a call that joined encrypted and then went undecodable left nothing behind in call stats to diagnose it. Traces four things through the existing tracer pipeline: - whether the app attached a manager, recorded at session creation since setE2EEManager has to run before join, when no tracer exists yet - setE2EEManager rejected because the call already joined, which silently leaves the call unencrypted - native encryption events, throttled per event kind and track because decryption can fail per frame while the buffer drains on the stats interval; suppressed repeats are counted, not dropped - encryptor and decryptor attach failures, which withhold a track without ever reaching the SFU WebRTC exposes a single observer slot that setEventListener used to claim, so the SDK could not observe events without displacing the app. The manager now owns the slot and fans out to both listeners, isolating a throwing one from the other. Sessions register through an internal listener that clears only if still current, so a rejoin does not lose it. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(core): trace only E2EE setup, not native events Narrows the previous commit to the encryption setup. Native encryption events fire per frame on every client, which is more volume than call stats should carry, and apps already observe them through StreamEncryptionManager.setEventListener. Removes the native event trace and its throttle, and the encryptor and decryptor attach-failure traces, which keep their existing logs. The observer fan-out goes with them: it existed so SDK tracing could share WebRTC's single observer slot with the app, and with no SDK listener left setEventListener owns the slot directly again. What remains is one trace per session recording whether a manager was attached and which algorithm it uses, plus the setE2EEManager call that was rejected for arriving after join. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(core): raise the unit test heap so Robolectric can load The unit test job failed with OutOfMemoryError loading android-all-instrumented-13.jar, in whichever Robolectric class ran first: IncomingCallPresenterTest, ServiceLauncherTest, TelecomPermissionsTest, ForegroundServicePermissionManagerTest. Gradle forks one test worker with a 512 MB heap by default, and unlike isolatedTest the main task does not set forkEvery, so the whole suite shares that worker. Robolectric's android-all jar never comfortably fit, and it tipped over as the suite grew. Reruns sometimes passed, which is what a marginal heap looks like rather than a flake. Raising the worker to 4g fixes it, matching what stream-video-android-ui-compose already does for Paparazzi. Verified by reproducing the exact failure locally at 512m and confirming all 1117 tests pass at 4g. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 083e929 commit 4294af4

34 files changed

Lines changed: 2463 additions & 51 deletions
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.video.android.ui.lobby
18+
19+
import androidx.compose.foundation.layout.Column
20+
import androidx.compose.foundation.layout.fillMaxWidth
21+
import androidx.compose.foundation.layout.padding
22+
import androidx.compose.foundation.text.KeyboardOptions
23+
import androidx.compose.material.Icon
24+
import androidx.compose.material.IconButton
25+
import androidx.compose.material.Text
26+
import androidx.compose.material.icons.Icons
27+
import androidx.compose.material.icons.filled.Lock
28+
import androidx.compose.material.icons.filled.LockOpen
29+
import androidx.compose.runtime.Composable
30+
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.mutableStateOf
32+
import androidx.compose.runtime.remember
33+
import androidx.compose.runtime.rememberCoroutineScope
34+
import androidx.compose.runtime.setValue
35+
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.platform.testTag
37+
import androidx.compose.ui.text.input.KeyboardType
38+
import androidx.compose.ui.text.input.PasswordVisualTransformation
39+
import androidx.compose.ui.text.input.TextFieldValue
40+
import androidx.compose.ui.unit.dp
41+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
42+
import io.getstream.video.android.compose.theme.VideoTheme
43+
import io.getstream.video.android.compose.ui.components.base.StreamDialogPositiveNegative
44+
import io.getstream.video.android.compose.ui.components.base.StreamTextField
45+
import io.getstream.video.android.compose.ui.components.base.styling.ButtonStyles
46+
import io.getstream.video.android.compose.ui.components.base.styling.StreamDialogStyles
47+
import io.getstream.video.android.core.Call
48+
import kotlinx.coroutines.launch
49+
import javax.crypto.SecretKeyFactory
50+
import javax.crypto.spec.PBEKeySpec
51+
52+
/**
53+
* Salt, iteration count and key length are fixed across the web, iOS and Android demos so the same
54+
* passphrase produces the same key on every platform — otherwise a cross-platform test call just
55+
* silently fails to decrypt. Real integrations distribute key material out of band instead of
56+
* deriving it from a secret typed into a UI, which is why this lives in the demo and not the SDK.
57+
*/
58+
private const val KDF_SALT = "stream-e2ee"
59+
private const val KDF_ITERATIONS = 100_000
60+
private const val KDF_KEY_BITS = 128
61+
62+
/** Derives the AES-128 key that the demo shares between participants. */
63+
internal fun deriveE2EEKey(passphrase: String): ByteArray {
64+
val spec = PBEKeySpec(
65+
passphrase.toCharArray(),
66+
KDF_SALT.toByteArray(Charsets.UTF_8),
67+
KDF_ITERATIONS,
68+
KDF_KEY_BITS,
69+
)
70+
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
71+
}
72+
73+
/**
74+
* Lobby toggle for end-to-end encryption. It belongs here rather than in the in-call menu because
75+
* keys have to be set before [Call.join] — the publisher and subscriber capture the encryption
76+
* manager when the session is created.
77+
*/
78+
@Composable
79+
internal fun E2EELobbyButton(
80+
call: Call,
81+
onEnable: suspend (String) -> Result<Unit>,
82+
onDisable: () -> Result<Unit>,
83+
modifier: Modifier = Modifier,
84+
) {
85+
val encrypted by call.state.e2eeEnabled.collectAsStateWithLifecycle()
86+
var showDialog by remember { mutableStateOf(false) }
87+
var error by remember { mutableStateOf<String?>(null) }
88+
val scope = rememberCoroutineScope()
89+
90+
IconButton(
91+
modifier = modifier.testTag("Stream_LobbyE2EEButton"),
92+
onClick = {
93+
if (encrypted) {
94+
error = onDisable().exceptionOrNull()?.message
95+
} else {
96+
showDialog = true
97+
}
98+
},
99+
) {
100+
Icon(
101+
imageVector = if (encrypted) Icons.Default.Lock else Icons.Default.LockOpen,
102+
contentDescription = if (encrypted) "Disable encryption" else "Enable encryption",
103+
tint = if (encrypted) {
104+
VideoTheme.colors.brandPrimary
105+
} else {
106+
VideoTheme.colors.basePrimary
107+
},
108+
)
109+
}
110+
111+
if (showDialog) {
112+
E2EEPassphraseDialog(
113+
error = error,
114+
onDismiss = {
115+
showDialog = false
116+
error = null
117+
},
118+
onConfirm = { passphrase ->
119+
// Surfaced rather than swallowed: joining a call the user believes is encrypted
120+
// when it is not would be worse than refusing to enable it.
121+
scope.launch {
122+
val failure = onEnable(passphrase).exceptionOrNull()
123+
error = failure?.message ?: failure?.javaClass?.simpleName
124+
showDialog = failure != null
125+
}
126+
},
127+
)
128+
}
129+
}
130+
131+
@Composable
132+
private fun E2EEPassphraseDialog(
133+
error: String?,
134+
onDismiss: () -> Unit,
135+
onConfirm: (String) -> Unit,
136+
) {
137+
var passphrase by remember { mutableStateOf(TextFieldValue("")) }
138+
var validationError by remember { mutableStateOf<String?>(null) }
139+
val fieldError = validationError ?: error
140+
141+
StreamDialogPositiveNegative(
142+
style = StreamDialogStyles.defaultDialogStyle(),
143+
onDismiss = onDismiss,
144+
icon = Icons.Default.Lock,
145+
title = "Encrypt this call",
146+
contentText = "Everyone on the call has to use the same passphrase. Recording, " +
147+
"transcription, closed captions and HLS are unavailable on encrypted calls.",
148+
content = {
149+
Column(modifier = Modifier.fillMaxWidth()) {
150+
StreamTextField(
151+
modifier = Modifier
152+
.fillMaxWidth()
153+
.padding(16.dp)
154+
.testTag("Stream_E2EEPassphraseField"),
155+
value = passphrase,
156+
onValueChange = {
157+
passphrase = it
158+
validationError = null
159+
},
160+
placeholder = "Passphrase",
161+
error = fieldError != null,
162+
visualTransformation = PasswordVisualTransformation(),
163+
keyboardOptions = KeyboardOptions.Default.copy(
164+
keyboardType = KeyboardType.Password,
165+
),
166+
)
167+
if (fieldError != null) {
168+
Text(
169+
modifier = Modifier.padding(horizontal = 16.dp),
170+
text = fieldError,
171+
style = VideoTheme.typography.bodyS,
172+
color = VideoTheme.colors.alertWarning,
173+
)
174+
}
175+
}
176+
},
177+
positiveButton = Triple("Enable", ButtonStyles.secondaryButtonStyle()) {
178+
if (passphrase.text.isBlank()) {
179+
validationError = "Passphrase cannot be empty."
180+
} else {
181+
onConfirm(passphrase.text)
182+
}
183+
},
184+
negativeButton = Triple("Cancel", ButtonStyles.tertiaryButtonStyle()) {
185+
onDismiss()
186+
},
187+
)
188+
}

demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyScreen.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,13 @@ private fun CallLobbyHeader(
164164
callLobbyViewModel = callLobbyViewModel,
165165
)
166166

167-
CallLobbyHeaderContent(user, onBack)
167+
CallLobbyHeaderContent(
168+
user = user,
169+
call = callLobbyViewModel.call,
170+
onEnableE2EE = callLobbyViewModel::enableE2EE,
171+
onDisableE2EE = callLobbyViewModel::disableE2EE,
172+
onBack = onBack,
173+
)
168174

169175
LaunchedEffect(key1 = isLoggedOut) {
170176
if (isLoggedOut) {
@@ -176,6 +182,9 @@ private fun CallLobbyHeader(
176182
@Composable
177183
private fun CallLobbyHeaderContent(
178184
user: State<User?>,
185+
call: Call,
186+
onEnableE2EE: suspend (String) -> Result<Unit>,
187+
onDisableE2EE: () -> Result<Unit>,
179188
onBack: () -> Unit,
180189
) {
181190
Row(
@@ -207,6 +216,13 @@ private fun CallLobbyHeaderContent(
207216
maxLines = 1,
208217
fontSize = 16.sp,
209218
)
219+
E2EELobbyButton(
220+
call = call,
221+
onEnable = onEnableE2EE,
222+
onDisable = onDisableE2EE,
223+
modifier = Modifier.padding(8.dp),
224+
)
225+
210226
IconButton(
211227
modifier = Modifier
212228
.padding(8.dp)
@@ -542,6 +558,9 @@ private fun CallLobbyHeaderPreview() {
542558
user = remember {
543559
mutableStateOf(previewUsers[0])
544560
},
561+
call = previewCall,
562+
onEnableE2EE = { Result.success(Unit) },
563+
onDisableE2EE = { Result.success(Unit) },
545564
) {
546565
}
547566
}

demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyViewModel.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ import io.getstream.chat.android.client.ChatClient
2626
import io.getstream.video.android.core.Call
2727
import io.getstream.video.android.core.DeviceStatus
2828
import io.getstream.video.android.core.StreamVideo
29+
import io.getstream.video.android.core.e2ee.E2EEEventType
30+
import io.getstream.video.android.core.e2ee.StreamEncryptionManager
2931
import io.getstream.video.android.datastore.delegate.StreamUserDataStore
3032
import io.getstream.video.android.model.StreamCallId
3133
import io.getstream.video.android.model.User
34+
import kotlinx.coroutines.Dispatchers
3235
import kotlinx.coroutines.delay
3336
import kotlinx.coroutines.flow.Flow
3437
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -44,6 +47,7 @@ import kotlinx.coroutines.flow.onCompletion
4447
import kotlinx.coroutines.flow.shareIn
4548
import kotlinx.coroutines.flow.stateIn
4649
import kotlinx.coroutines.launch
50+
import kotlinx.coroutines.withContext
4751
import stream.video.sfu.models.AudioBitrateProfile
4852
import javax.inject.Inject
4953

@@ -54,6 +58,12 @@ class CallLobbyViewModel @Inject constructor(
5458
private val googleSignInClient: GoogleSignInClient,
5559
) : ViewModel() {
5660

61+
private companion object {
62+
const val E2EE_KEY_INDEX = 0
63+
}
64+
65+
private var e2eeManager: StreamEncryptionManager? = null
66+
5767
private val cid: String = checkNotNull(savedStateHandle["cid"])
5868
val callId: StreamCallId = StreamCallId.fromCallCid(cid)
5969

@@ -66,6 +76,11 @@ class CallLobbyViewModel @Inject constructor(
6676
// this way the lobby screen can already display the right mic/camera settings
6777
// This also starts listening to the call events to get the participant count
6878
val callGetOrCreateResult = call.create()
79+
Log.i(
80+
"CallLobbyViewModel",
81+
"Call ${call.cid} encryption mode=" +
82+
"${call.state.settings.value?.encryption?.mode}",
83+
)
6984
if (callGetOrCreateResult.isFailure) {
7085
// in demo we can ignore this. The lobby screen will just display default camera/video,
7186
// but we will show an error
@@ -192,6 +207,54 @@ class CallLobbyViewModel @Inject constructor(
192207
}
193208
}
194209

210+
suspend fun enableE2EE(passphrase: String): Result<Unit> {
211+
val key = runCatching {
212+
withContext(Dispatchers.Default) { deriveE2EEKey(passphrase) }
213+
}.getOrElse { return Result.failure(it) }
214+
val created = StreamEncryptionManager.create(call.user.id)
215+
.getOrElse { return Result.failure(it) }
216+
created.setEventListener { event ->
217+
val message = "Native E2EE event: $event"
218+
when (event.type) {
219+
E2EEEventType.DECRYPTION_RESUMED -> Log.i("CallLobbyViewModel", message)
220+
E2EEEventType.KEY_STATE,
221+
E2EEEventType.PERF_REPORT,
222+
-> Log.d("CallLobbyViewModel", message)
223+
E2EEEventType.DECRYPTION_FAILED,
224+
E2EEEventType.DECRYPTION_STALLED,
225+
E2EEEventType.ENCRYPTION_FAILED,
226+
E2EEEventType.MISSING_KEY,
227+
E2EEEventType.UNENCRYPTED_FRAME,
228+
E2EEEventType.UNSUPPORTED_VERSION,
229+
E2EEEventType.UNKNOWN,
230+
-> Log.w("CallLobbyViewModel", message)
231+
}
232+
}
233+
created.enablePerformanceReporting(true)
234+
created.setSharedKey(E2EE_KEY_INDEX, key)
235+
val attached = call.setE2EEManager(created)
236+
if (attached.isFailure) {
237+
created.dispose()
238+
return attached
239+
}
240+
val previous = e2eeManager
241+
e2eeManager = created
242+
previous?.dispose()
243+
return Result.success(Unit)
244+
}
245+
246+
fun disableE2EE(): Result<Unit> {
247+
val current = e2eeManager
248+
val detached = call.setE2EEManager(null)
249+
if (detached.isFailure) return detached
250+
try {
251+
current?.dispose()
252+
} finally {
253+
e2eeManager = null
254+
}
255+
return Result.success(Unit)
256+
}
257+
195258
fun signOut() {
196259
viewModelScope.launch {
197260
googleSignInClient.signOut()

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ tink = "1.9.0"
4949
turbine = "0.13.0"
5050
itu = "1.7.3"
5151

52-
streamWebRTC = "145.6.0"
52+
streamWebRTC = "145.17.0"
5353
streamNoiseCancellation = "3.0.0"
5454
streamResult = "1.3.0"
5555
streamChat = "6.10.0"

0 commit comments

Comments
 (0)