diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 561b0fa0404..213b27d863e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -338,6 +338,7 @@ dependencies { implementation("com.github.nextcloud.android-common:ui:0.34.0") implementation("com.github.nextcloud.android-common:core:0.34.0") implementation("com.github.nextcloud-deps:android-talk-webrtc:155.8059.1") + implementation("com.github.nextcloud-releases:talk-clients-vodozemac:0.0.2") "gplayImplementation"("com.google.android.gms:play-services-base:18.11.1") "gplayImplementation"("com.google.firebase:firebase-messaging:26.0.0") diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 4b741b67544..81cb8972531 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -46,6 +46,7 @@ import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.LaunchedEffect @@ -76,6 +77,8 @@ import com.nextcloud.talk.call.MediaConstraintsHelper import com.nextcloud.talk.call.MessageSender import com.nextcloud.talk.call.MessageSenderMcu import com.nextcloud.talk.call.MessageSenderNoMcu +import com.nextcloud.talk.call.e2ee.CallEncryption +import com.nextcloud.talk.call.e2ee.FrameKeyRing import com.nextcloud.talk.call.MutableLocalCallParticipantModel import com.nextcloud.talk.call.ReactionAnimator import com.nextcloud.talk.call.components.ParticipantGrid @@ -322,6 +325,7 @@ class CallActivity : CallBaseActivity() { private var roomJoinRefreshes = 0 private var hasMCU = false private var hasExternalSignalingServer = false + private var isCallEndToEndEncryptionEnabled = false private var conversationPassword: String? = null private var powerManagerUtils: PowerManagerUtils? = null @@ -524,10 +528,8 @@ class CallActivity : CallBaseActivity() { return } processExtras(intent.extras!!) - - if (warnAndFinishIfCallEndToEndEncryptionUnsupported()) { - return - } + isCallEndToEndEncryptionEnabled = + CapabilitiesUtil.isCallEndToEndEncryptionEnabled(conversationUser.capabilities?.spreedCapability) credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) if (TextUtils.isEmpty(baseUrl)) { @@ -570,15 +572,58 @@ class CallActivity : CallBaseActivity() { checkInitialDevicePermissions() } - private fun warnAndFinishIfCallEndToEndEncryptionUnsupported(): Boolean { - if (!CapabilitiesUtil.isCallEndToEndEncryptionEnabled(conversationUser?.capabilities?.spreedCapability)) { + /** + * Ends the call when it has to be end-to-end encrypted but can not be; media is never sent unencrypted instead. + */ + private fun failCallEncryption(@StringRes reason: Int) { + if (currentCallStatus === CallStatus.LEAVING) { + return + } + Log.e(TAG, "Ending call: ${context.getString(reason)}") + runOnUiThread { Toast.makeText(context, reason, Toast.LENGTH_LONG).show() } + hangup(shutDownView = true, endCallForAll = false) + } + + /** + * The key exchange of the call, null when calls are not end-to-end encrypted or it is not set up yet. + */ + private fun callEncryption(): CallEncryption? = + if (isCallEndToEndEncryptionEnabled) webSocketClient?.callEncryption else null + + // Encrypted calls only work through the MCU, which is the only place with a key exchange + private fun ensureMcuForCallEncryption(): Boolean { + if (isCallEndToEndEncryptionEnabled && !hasMCU) { + failCallEncryption(R.string.nc_call_e2ee_requires_hpb) + return false + } + return true + } + + // The key exchange is created when the room is joined, so it has to exist before publishing + private fun ensureCallEncryption(): Boolean { + if (isCallEndToEndEncryptionEnabled && callEncryption() == null) { + failCallEncryption(R.string.nc_call_e2ee_setup_failed) return false } - Toast.makeText(context, R.string.nc_call_e2ee_not_supported, Toast.LENGTH_LONG).show() - finish() return true } + private fun createMessageSender(): MessageSender = + if (hasMCU) { + MessageSenderMcu( + signalingMessageSender, + getParticipantSessionKeys(), + peerConnectionWrapperList, + webSocketClient!!.sessionId + ) + } else { + MessageSenderNoMcu( + signalingMessageSender, + getParticipantSessionKeys(), + peerConnectionWrapperList + ) + } + private fun initCallRecordingViewModel(recordingState: Int) { callRecordingViewModel = ViewModelProvider(this, viewModelFactory).get( CallRecordingViewModel::class.java @@ -1717,8 +1762,16 @@ class CallActivity : CallBaseActivity() { override fun onNext(capabilitiesOverall: CapabilitiesOverall) { // FIXME check for compatible Call API version + // The policy comes from these fresh capabilities, not from the cached ones of the user + capabilitiesOverall.ocs?.data?.capabilities?.let { + conversationUser.capabilities = it + isCallEndToEndEncryptionEnabled = + CapabilitiesUtil.isCallEndToEndEncryptionEnabled(it.spreedCapability) + } if (hasExternalSignalingServer) { setupAndInitiateWebSocketsConnection() + } else if (isCallEndToEndEncryptionEnabled) { + failCallEncryption(R.string.nc_call_e2ee_requires_hpb) } else { signalingMessageReceiver = internalSignalingMessageReceiver signalingMessageReceiver!!.addListener(localParticipantMessageListener) @@ -2060,25 +2113,16 @@ class CallActivity : CallBaseActivity() { hasMCU = webSocketClient!!.hasMCU() Log.d(TAG, "hasMCU is $hasMCU") - if (hasMCU) { - messageSender = MessageSenderMcu( - signalingMessageSender, - getParticipantSessionKeys(), - peerConnectionWrapperList, - webSocketClient!!.sessionId - ) - } else { - messageSender = MessageSenderNoMcu( - signalingMessageSender, - getParticipantSessionKeys(), - peerConnectionWrapperList - ) - } + messageSender = createMessageSender() } else { if (webSocketClient!!.isConnected && currentCallStatus === CallStatus.PUBLISHER_FAILED) { webSocketClient!!.restartWebSocket() } } + // An already connected signaling server sends no new "hello", so the MCU requirement is checked here + if (webSocketClient!!.isConnected && !ensureMcuForCallEncryption()) { + return + } joinRoomAndCall() } @@ -2104,21 +2148,12 @@ class CallActivity : CallBaseActivity() { hasMCU = webSocketClient!!.hasMCU() Log.d(TAG, "hasMCU is $hasMCU") - if (hasMCU) { - messageSender = MessageSenderMcu( - signalingMessageSender, - getParticipantSessionKeys(), - peerConnectionWrapperList, - webSocketClient!!.sessionId - ) - } else { - messageSender = MessageSenderNoMcu( - signalingMessageSender, - getParticipantSessionKeys(), - peerConnectionWrapperList - ) + if (!ensureMcuForCallEncryption()) { + return } + messageSender = createMessageSender() + if (!webSocketCommunicationEvent.getHashMap()!!.containsKey("oldResumeId")) { if (currentCallStatus === CallStatus.RECONNECTING) { hangup(false, false) @@ -2501,7 +2536,7 @@ class CallActivity : CallBaseActivity() { removeSessions(participantsInCall) return } - if (currentCallStatus === CallStatus.LEAVING) { + if (currentCallStatus === CallStatus.LEAVING || (hasMCU && !ensureCallEncryption())) { return } if (hasMCU) { @@ -2537,6 +2572,9 @@ class CallActivity : CallBaseActivity() { var selfJoined = false val selfParticipantHasAudioOrVideo = participantInCallFlagsHaveAudioOrVideo(selfParticipant) for (participant in joined) { + if (currentCallStatus === CallStatus.LEAVING) { + return + } val sessionId = participant.sessionId if (sessionId == null) { Log.w(TAG, "Null sessionId for call participant, this should not happen: $participant") @@ -2653,6 +2691,7 @@ class CallActivity : CallBaseActivity() { return null } peerConnectionWrapper = createPeerConnectionWrapperForSessionIdAndType(publisher, sessionId, type) + ?: return null synchronized(remoteAudioPlayoutLock) { peerConnectionWrapperList.add(peerConnectionWrapper) peerConnectionWrapper.setRemoteAudioPlayoutEnabled(remoteAudioPlayoutEnabled) @@ -2680,7 +2719,7 @@ class CallActivity : CallBaseActivity() { publisher: Boolean, sessionId: String?, type: String - ): PeerConnectionWrapper { + ): PeerConnectionWrapper? { fun getPeerConnectionFactory(type: String): PeerConnectionFactory? { fun initScreenSharePeerConnectionFactory(): PeerConnectionFactory? { val options = PeerConnectionFactory.Options() @@ -2739,23 +2778,48 @@ class CallActivity : CallBaseActivity() { } } - return PeerConnectionWrapper( - tempPeerConnectionFactory, - iceServers, - tempSdpConstraints, - sessionId, - // #6169: the remote sessionId is a signaling-layer session ID when external signaling - // is used, so the offerer election must compare same-namespace IDs. Pass the local - // signaling session (matching the currentSessionId resolution used elsewhere); with - // internal signaling webSocketClient == null, so callSession is used as before. - if (webSocketClient != null) webSocketClient!!.sessionId else callSession, - tempLocalStream, - tempIsMCUPublisher, - tempHasMCU, - type, - signalingMessageReceiver, - signalingMessageSender - ) + val (senderKeyRing, receiverKeyRing) = keyRingsFor(tempIsMCUPublisher, tempHasMCU, sessionId) + + return try { + PeerConnectionWrapper( + tempPeerConnectionFactory, + iceServers, + tempSdpConstraints, + sessionId, + // #6169: the remote sessionId is a signaling-layer session ID when external signaling + // is used, so the offerer election must compare same-namespace IDs. Pass the local + // signaling session (matching the currentSessionId resolution used elsewhere); with + // internal signaling webSocketClient == null, so callSession is used as before. + if (webSocketClient != null) webSocketClient!!.sessionId else callSession, + tempLocalStream, + tempIsMCUPublisher, + tempHasMCU, + type, + signalingMessageReceiver, + signalingMessageSender, + isCallEndToEndEncryptionEnabled, + senderKeyRing, + receiverKeyRing + ) + } catch (e: IllegalStateException) { + Log.e(TAG, "Not creating a connection to $sessionId", e) + failCallEncryption(R.string.nc_call_e2ee_setup_failed) + null + } + } + + // The key exchange only exists with the MCU: own keys go to the publisher, the remote keys to each subscriber. + private fun keyRingsFor( + isMCUPublisher: Boolean, + hasMCU: Boolean, + sessionId: String? + ): Pair { + val callEncryption = callEncryption() ?: return null to null + return when { + isMCUPublisher -> callEncryption.ownKeyRing to null + hasMCU && sessionId != null -> null to callEncryption.keyRing(sessionId) + else -> null to null + } } private fun addCallParticipant(sessionId: String?) { diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt new file mode 100644 index 00000000000..9d00b8936f2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt @@ -0,0 +1,436 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import android.util.Log +import com.bluelinelabs.logansquare.LoganSquare +import com.nextcloud.talk.models.json.signaling.FrameKeyDto +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID + +/** + * Exchanges the frame keys of a call with every other session in the room. + * + * Port of spreed src/utils/e2ee/encryption.js, so keys can be exchanged with the web client. Each participant sends + * its own random key to everyone else, encrypted with a one-to-one Olm session over signaling, so the signaling + * server and the MCU never see it. + * + * All state is handled on a single-threaded dispatcher; [keyRing] can be called from any thread. + */ +// Guard clauses with early returns keep the message handlers readable and close to the web client's code +@Suppress("TooManyFunctions", "ReturnCount") +class CallEncryption( + private val ownSessionId: String, + private val olm: OlmAccount, + private val frameCrypto: FrameCrypto, + private val sendMessage: (sessionId: String, message: EncryptionMessage) -> Unit, + dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1), + /** Debounce period of key updates and timeout of requests, the web client uses the same value for both. */ + private val periodMs: Long = WEB_CLIENT_PERIOD_MS +) { + /** Our own keys, used by the encryptors of all our senders. */ + val ownKeyRing: FrameKeyRing = frameCrypto.createKeyRing() + + private class SessionData( + var session: OlmSession? = null, + var startMessageId: String? = null, + var lastKey: ByteArray? = null + ) + + private class PendingRequest(val timeout: Job, val completion: CompletableDeferred) + + private class RemoteKey(val key: ByteArray, val index: Int) + + private val scope = CoroutineScope( + SupervisorJob() + dispatcher + CoroutineExceptionHandler { _, e -> Log.e(TAG, "Key exchange failed", e) } + ) + + private var key = generateKey() + private var keyIndex = 0 + private var isRotating = false + private var ratchetJob: Job? = null + private var rotateJob: Job? = null + private var isClosed = false + + private val sessions = HashMap() + private val pendingRequests = HashMap() + + // Read from WebRTC threads when receivers are created, so guarded by its own lock + private val remoteKeyRings = HashMap() + private var remoteKeyRingsClosed = false + + init { + ownKeyRing.setKey(key, keyIndex) + } + + /** The keys of a remote participant, used by the decryptors of all its streams, null once closed. */ + fun keyRing(sessionId: String): FrameKeyRing? = + synchronized(remoteKeyRings) { + if (remoteKeyRingsClosed) null else remoteKeyRings.getOrPut(sessionId) { frameCrypto.createKeyRing() } + } + + fun usersJoined(sessionIds: List) { + scope.launch { + if (isClosed) return@launch + + // Of every pair of sessions the one with the higher id starts the Olm session, compared like + // JavaScript compares strings (String.compareTo compares UTF-16 code units as well) + for (sessionId in sessionIds) { + if (sessionId < ownSessionId) { + startSession(sessionId) + } + } + + // A ratcheted key keeps new participants from decrypting what was sent before they joined, while + // everyone else follows by ratcheting on their own + ratchetJob = debounce(ratchetJob) { ratchetKey() } + } + } + + fun usersLeft(sessionIds: List) { + scope.launch { + if (isClosed) return@launch + + synchronized(remoteKeyRings) { + for (sessionId in sessionIds) { + sessions.remove(sessionId) + remoteKeyRings.remove(sessionId)?.dispose() + } + } + + // A new key keeps participants that left from decrypting what is sent from now on + rotateJob = debounce(rotateJob) { rotateKey() } + } + } + + fun handleMessage(sessionId: String, message: EncryptionMessage) { + scope.launch { + if (isClosed) return@launch + + when (message.type) { + EncryptionMessage.START -> processStartSession(sessionId, message) + EncryptionMessage.FINISH -> processFinishSession(sessionId, message) + EncryptionMessage.SET_KEY -> processSetKey(sessionId, message) + EncryptionMessage.GOT_KEY -> processGotKey(sessionId, message) + EncryptionMessage.ERROR -> Log.w(TAG, "Received error from $sessionId: ${message.error}") + } + } + } + + fun close() { + scope.launch { + isClosed = true + ratchetJob?.cancel() + rotateJob?.cancel() + ratchetJob = null + rotateJob = null + + for (request in pendingRequests.values) { + request.timeout.cancel() + request.completion.complete(Unit) + } + pendingRequests.clear() + sessions.values.forEach { + it.lastKey?.fill(0) + it.session?.close() + } + sessions.clear() + olm.close() + key.fill(0) + + synchronized(remoteKeyRings) { + remoteKeyRingsClosed = true + remoteKeyRings.values.forEach { it.dispose() } + remoteKeyRings.clear() + } + ownKeyRing.dispose() + + scope.cancel() + } + } + + private fun startSession(sessionId: String) { + val sessionData = sessions.getOrPut(sessionId) { SessionData() } + if (sessionData.session != null || sessionData.startMessageId != null) { + Log.d(TAG, "Session with $sessionId already exists or is being started") + return + } + + val oneTimeKey = olm.createOneTimeKey() + val messageId = UUID.randomUUID().toString() + sessionData.startMessageId = messageId + + addRequest(messageId) { + Log.w(TAG, "Starting session with $sessionId timed out") + // Only this request's session data: the participant may have left and rejoined in the meantime + sessionData.startMessageId = null + } + + sendMessage( + sessionId, + EncryptionMessage(EncryptionMessage.START, id = messageId, identity = olm.identityKey, key = oneTimeKey) + ) + } + + // The other side started a session, the one creating it from the start message is the "outbound" side in Olm + // terms + private fun processStartSession(sessionId: String, message: EncryptionMessage) { + if (sessions[sessionId]?.session != null) { + Log.w(TAG, "Already has a session with $sessionId") + sendError(sessionId, "Session already created") + return + } + + val messageId = message.id + val theirIdentityKey = message.identity + val theirOneTimeKey = message.key as? String + if (messageId == null || theirIdentityKey == null || theirOneTimeKey == null) { + Log.w(TAG, "Invalid start message from $sessionId") + return + } + + val session = runCatching { olm.createOutboundSession(theirIdentityKey, theirOneTimeKey) } + .onFailure { Log.w(TAG, "Could not create outbound session with $sessionId", it) } + .getOrNull() ?: return + + sessions.getOrPut(sessionId) { SessionData() }.session = session + Log.d(TAG, "Created outbound Olm session with $sessionId") + + sendMessage(sessionId, EncryptionMessage(EncryptionMessage.FINISH, id = messageId, key = encryptKey(session))) + } + + private fun processFinishSession(sessionId: String, message: EncryptionMessage) { + val sessionData = sessions[sessionId] + if (sessionData?.session != null) { + Log.w(TAG, "Already has a session with $sessionId") + sendError(sessionId, "Session already created") + return + } + + val messageId = message.id + if (messageId == null || messageId != sessionData?.startMessageId) { + Log.w(TAG, "Received finish with wrong id from $sessionId") + sendError(sessionId, "Finish has wrong id") + return + } + + val preKeyMessage = message.olmKey + if (preKeyMessage == null) { + Log.w(TAG, "Invalid finish message from $sessionId") + return + } + + val (session, plaintext) = runCatching { olm.createInboundSession(preKeyMessage) } + .onFailure { Log.w(TAG, "Could not create inbound session with $sessionId", it) } + .getOrNull() ?: return + + // The finish message already carries the remote key + val remoteKey = parseKey(plaintext) + + sessionData.session = session + sessionData.startMessageId = null + resolveRequest(messageId) + + Log.d( + TAG, + "Created inbound Olm session with $sessionId" + if (remoteKey == null) ", without a readable key" else "" + ) + + if (remoteKey != null) { + updateRemoteKey(sessionId, remoteKey) + } + + sendKey(sessionId) + } + + private fun processSetKey(sessionId: String, message: EncryptionMessage) { + val session = sessions[sessionId]?.session + if (session == null) { + Log.w(TAG, "No session with $sessionId for setting key") + sendError(sessionId, "No session for setting key") + return + } + + val messageId = message.id + val encryptedKey = message.olmKey + if (messageId == null || encryptedKey == null) { + Log.w(TAG, "Invalid setkey message from $sessionId") + return + } + + val remoteKey = decryptKey(encryptedKey, session) + if (remoteKey == null) { + Log.w(TAG, "Could not decrypt setkey from $sessionId, key type ${encryptedKey.type}") + return + } + + updateRemoteKey(sessionId, remoteKey) + + // Confirms the key, the sender waits for this before encrypting with it + sendMessage(sessionId, EncryptionMessage(EncryptionMessage.GOT_KEY, id = messageId, key = encryptKey(session))) + } + + private fun processGotKey(sessionId: String, message: EncryptionMessage) { + val session = sessions[sessionId]?.session + if (session == null) { + Log.w(TAG, "No session with $sessionId for confirming key") + sendError(sessionId, "No session for confirming key") + return + } + + // Only a confirmation that decrypts counts, like in the web client, so nobody can confirm for the recipient + val remoteKey = message.olmKey?.let { decryptKey(it, session) } ?: return + updateRemoteKey(sessionId, remoteKey) + message.id?.let { resolveRequest(it) } + } + + // Completes once the recipient confirmed the key or the request timed out + private fun sendKey(sessionId: String): CompletableDeferred { + val completion = CompletableDeferred() + val session = sessions[sessionId]?.session + if (session == null) { + completion.complete(Unit) + return completion + } + + val messageId = UUID.randomUUID().toString() + addRequest(messageId, completion) { + Log.w(TAG, "Sending key to $sessionId timed out") + } + + sendMessage(sessionId, EncryptionMessage(EncryptionMessage.SET_KEY, id = messageId, key = encryptKey(session))) + + return completion + } + + private fun updateRemoteKey(sessionId: String, remoteKey: RemoteKey) { + val sessionData = sessions[sessionId] ?: return + if (sessionData.lastKey?.contentEquals(remoteKey.key) == true) { + return + } + + sessionData.lastKey?.fill(0) + sessionData.lastKey = remoteKey.key + if (keyRing(sessionId)?.setKey(remoteKey.key, remoteKey.index) != true) { + Log.w(TAG, "Could not store key ${remoteKey.index} of $sessionId") + } + } + + private fun ratchetKey() { + if (isRotating) { + Log.d(TAG, "Not ratcheting key, currently rotating") + return + } + + // Not distributed, receivers find the ratcheted key on their own + val ratchetedKey = frameCrypto.ratchetKey(key) + key.fill(0) + key = ratchetedKey + ownKeyRing.setKey(key, keyIndex) + } + + private fun rotateKey() { + isRotating = true + // Superseded key material is wiped, the native key ring keeps its own copy for late frames + key.fill(0) + key = generateKey() + keyIndex++ + + val newKey = key + val newKeyIndex = keyIndex + Log.d(TAG, "Rotating own key to index $newKeyIndex") + + val confirmations = sessions.keys.toList().map { sendKey(it) } + + // Only encrypt with the new key once everyone has it or timed out + scope.launch { + confirmations.awaitAll() + // A later rotation has replaced this key already + if (isClosed || newKeyIndex != keyIndex) return@launch + + ownKeyRing.setKey(newKey, newKeyIndex) + isRotating = false + } + } + + // The key as the web client sends it, JSON encrypted with the Olm session + private fun encryptKey(session: OlmSession): Map { + val json = LoganSquare.serialize(FrameKeyDto(Base64.getEncoder().encodeToString(key), keyIndex)) + return session.encrypt(json).asKey() + } + + private fun OlmMessage.asKey(): Map = mapOf("type" to type, "body" to body) + + private fun decryptKey(encryptedKey: OlmMessage, session: OlmSession): RemoteKey? = + runCatching { session.decrypt(encryptedKey) }.getOrNull()?.let { parseKey(it) } + + // {"key": "", "index": }, null when the plaintext is not that + private fun parseKey(json: String): RemoteKey? { + val frameKey = runCatching { LoganSquare.parse(json, FrameKeyDto::class.java) }.getOrNull() ?: return null + val index = frameKey.index ?: return null + val keyBytes = frameKey.key?.let { runCatching { Base64.getDecoder().decode(it) }.getOrNull() } ?: return null + return RemoteKey(keyBytes, index) + } + + private fun sendError(sessionId: String, error: String) { + sendMessage(sessionId, EncryptionMessage(EncryptionMessage.ERROR, error = error)) + } + + private fun addRequest( + messageId: String, + completion: CompletableDeferred = CompletableDeferred(), + onTimeout: () -> Unit + ) { + val timeout = scope.launch { + delay(periodMs) + val request = pendingRequests.remove(messageId) ?: return@launch + onTimeout() + request.completion.complete(Unit) + } + pendingRequests[messageId] = PendingRequest(timeout, completion) + } + + private fun resolveRequest(messageId: String) { + val request = pendingRequests.remove(messageId) ?: return + request.timeout.cancel() + request.completion.complete(Unit) + } + + // Runs the action once no new call came in for the debounce period, like the web client's debounce + private fun debounce(job: Job?, action: () -> Unit): Job { + job?.cancel() + return scope.launch { + delay(periodMs) + if (!isClosed) { + action() + } + } + } + + companion object { + private val TAG = CallEncryption::class.java.simpleName + + private const val WEB_CLIENT_PERIOD_MS = 5000L + private const val KEY_SIZE = 32 + + private val random = SecureRandom() + + private fun generateKey(): ByteArray = ByteArray(KEY_SIZE).also { random.nextBytes(it) } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt new file mode 100644 index 00000000000..7f9fa058768 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import android.util.Log + +/** + * Creates the key exchange of a call with the Olm and frame encryption implementations of this build. + */ +object CallEncryptionFactory { + private val TAG = CallEncryptionFactory::class.java.simpleName + + /** + * Returns null when the native libraries can not be used, the call then ends instead of being sent unencrypted. + */ + @Suppress("TooGenericExceptionCaught") + fun create( + ownSessionId: String, + sendMessage: (sessionId: String, message: EncryptionMessage) -> Unit + ): CallEncryption? = + try { + CallEncryption(ownSessionId, VodozemacOlmAccount(), TalkFrameCrypto, sendMessage) + } catch (e: Exception) { + Log.e(TAG, "Could not set up the key exchange for $ownSessionId", e) + null + } catch (e: LinkageError) { + Log.e(TAG, "Could not load the native encryption libraries for $ownSessionId", e) + null + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/EncryptionMessage.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/EncryptionMessage.kt new file mode 100644 index 00000000000..a69999c701a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/EncryptionMessage.kt @@ -0,0 +1,57 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import com.nextcloud.talk.models.json.signaling.NCMessagePayloadDto + +/** + * The payload of a key exchange signaling message, with the field names of the web client. + * + * [key] is the one-time key (a String) in a start message and an Olm message (a Map with "type" and "body") + * otherwise. + */ +data class EncryptionMessage( + val type: String, + val id: String? = null, + val identity: String? = null, + val key: Any? = null, + val error: String? = null +) { + val olmKey: OlmMessage? + get() { + val map = key as? Map<*, *> ?: return null + val type = (map["type"] as? Number)?.toInt() ?: return null + val body = map["body"] as? String ?: return null + return OlmMessage(type, body) + } + + fun toPayload(): NCMessagePayloadDto = + NCMessagePayloadDto().also { + it.type = type + it.id = id + it.identity = identity + it.key = key + it.error = error + } + + companion object { + const val START = "encryption.start" + const val FINISH = "encryption.finish" + const val SET_KEY = "encryption.setkey" + const val GOT_KEY = "encryption.gotkey" + const val ERROR = "encryption.error" + + private val TYPES = setOf(START, FINISH, SET_KEY, GOT_KEY, ERROR) + + fun isEncryptionMessage(type: String?): Boolean = type in TYPES + + fun fromPayload(payload: NCMessagePayloadDto): EncryptionMessage? { + val type = payload.type?.takeIf { isEncryptionMessage(it) } ?: return null + return EncryptionMessage(type, payload.id, payload.identity, payload.key, payload.error) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/FrameCrypto.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/FrameCrypto.kt new file mode 100644 index 00000000000..6a732a3dbbe --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/FrameCrypto.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender + +/** + * The frame keys of one participant. Cryptors attached from it keep working after [dispose], but nothing can be + * attached anymore then. + */ +interface FrameKeyRing { + /** Stores the 32 byte key material for the given index and makes it the current key. */ + fun setKey(key: ByteArray, keyIndex: Int): Boolean + + /** Encrypts what the sender sends from now on, false when the ring is disposed already. */ + fun attachEncryptor(sender: RtpSender): Boolean + + /** Decrypts what the receiver receives from now on, false when the ring is disposed already. */ + fun attachDecryptor(receiver: RtpReceiver): Boolean + + fun dispose() +} + +/** + * Frame encryption as provided by the WebRTC library. + */ +interface FrameCrypto { + fun createKeyRing(): FrameKeyRing + + /** Derives the next key material from the given one, like the web client's ratchet. */ + fun ratchetKey(key: ByteArray): ByteArray +} diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/OlmAccount.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/OlmAccount.kt new file mode 100644 index 00000000000..134ff544a60 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/OlmAccount.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +/** + * An Olm message as it travels inside the key exchange payloads: type 0 is a pre-key message, type 1 a normal one. + */ +data class OlmMessage(val type: Int, val body: String) { + companion object { + const val TYPE_PRE_KEY = 0 + const val TYPE_NORMAL = 1 + } +} + +/** + * An Olm session with one other participant, used in both directions. + */ +interface OlmSession { + fun encrypt(plaintext: String): OlmMessage + + fun decrypt(message: OlmMessage): String + + /** Releases the session, nothing can be encrypted or decrypted with it afterwards. */ + fun close() +} + +/** + * The Olm account of one call session, backed by vodozemac in the app and by a fake in tests. + */ +interface OlmAccount { + /** Curve25519 identity key, unpadded base64. */ + val identityKey: String + + /** Creates a one-time key and marks it as published. */ + fun createOneTimeKey(): String + + fun createOutboundSession(theirIdentityKey: String, theirOneTimeKey: String): OlmSession + + /** + * Creates the session from a pre-key message and returns it with the decrypted message, as the message can not + * be decrypted again afterwards. + */ + fun createInboundSession(preKeyMessage: OlmMessage): Pair + + /** Releases the account and its keys. */ + fun close() +} diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/TalkFrameCrypto.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/TalkFrameCrypto.kt new file mode 100644 index 00000000000..36e61b02432 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/TalkFrameCrypto.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender +import org.webrtc.TalkKeyRing + +/** + * Frame encryption of the Talk WebRTC build, compatible with the web client's frame format. + */ +object TalkFrameCrypto : FrameCrypto { + override fun createKeyRing(): FrameKeyRing = TalkFrameKeyRing(TalkKeyRing()) + + override fun ratchetKey(key: ByteArray): ByteArray = + checkNotNull(TalkKeyRing.ratchetKey(key)) { "Can not ratchet an empty key" } + + // Attaching and disposing are serialized: dispose() frees the native cryptors, so a cryptor created on the + // signaling thread has to be attached before the ring can go away + private class TalkFrameKeyRing(private val keyRing: TalkKeyRing) : FrameKeyRing { + private var disposed = false + + @Synchronized + override fun setKey(key: ByteArray, keyIndex: Int): Boolean = !disposed && keyRing.setKey(key, keyIndex) + + @Synchronized + override fun attachEncryptor(sender: RtpSender): Boolean { + if (disposed) return false + sender.setFrameEncryptor(keyRing.createFrameEncryptor()) + return true + } + + @Synchronized + override fun attachDecryptor(receiver: RtpReceiver): Boolean { + if (disposed) return false + receiver.setFrameDecryptor(keyRing.createFrameDecryptor()) + return true + } + + @Synchronized + override fun dispose() { + if (!disposed) { + disposed = true + keyRing.dispose() + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/e2ee/VodozemacOlmAccount.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/VodozemacOlmAccount.kt new file mode 100644 index 00000000000..34144ed25f8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/VodozemacOlmAccount.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import com.nextcloud.talk.olm.VodozemacAccount +import com.nextcloud.talk.olm.VodozemacMessage +import com.nextcloud.talk.olm.VodozemacMessageKind +import com.nextcloud.talk.olm.VodozemacSession + +/** + * Olm through vodozemac, which talks libolm's protocol and can exchange keys with the web client. + * + * The native objects are released by [close], the cleaner of vodozemac only catches what was not closed. + */ +class VodozemacOlmAccount(private val account: VodozemacAccount = VodozemacAccount()) : OlmAccount { + override val identityKey: String by lazy { account.identityKey() } + + override fun createOneTimeKey(): String = account.createOneTimeKey() + + override fun createOutboundSession(theirIdentityKey: String, theirOneTimeKey: String): OlmSession = + VodozemacOlmSession(account.createOutboundSession(theirIdentityKey, theirOneTimeKey)) + + override fun createInboundSession(preKeyMessage: OlmMessage): Pair { + val inbound = account.createInboundSession(preKeyMessage.toVodozemac()) + return VodozemacOlmSession(inbound.session) to inbound.plaintext + } + + override fun close() = account.destroy() + + private class VodozemacOlmSession(private val session: VodozemacSession) : OlmSession { + override fun encrypt(plaintext: String): OlmMessage = session.encrypt(plaintext).toOlmMessage() + + override fun decrypt(message: OlmMessage): String = session.decrypt(message.toVodozemac()) + + override fun close() = session.destroy() + } + + private companion object { + fun OlmMessage.toVodozemac(): VodozemacMessage { + val kind = when (type) { + OlmMessage.TYPE_PRE_KEY -> VodozemacMessageKind.PRE_KEY + OlmMessage.TYPE_NORMAL -> VodozemacMessageKind.NORMAL + else -> throw IllegalArgumentException("Unknown Olm message type $type") + } + return VodozemacMessage(kind, body) + } + + fun VodozemacMessage.toOlmMessage(): OlmMessage { + val type = when (kind) { + VodozemacMessageKind.PRE_KEY -> OlmMessage.TYPE_PRE_KEY + VodozemacMessageKind.NORMAL -> OlmMessage.TYPE_NORMAL + } + return OlmMessage(type, body) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 52f81718151..28e65726787 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -15,7 +15,6 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.View -import android.widget.Toast import androidx.core.app.NotificationManagerCompat import autodagger.AutoInjector import com.nextcloud.talk.R @@ -29,7 +28,6 @@ import com.nextcloud.talk.databinding.CallNotificationActivityBinding import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.models.json.participants.ParticipantDto import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.SpreedFeatures @@ -198,11 +196,6 @@ class CallNotificationActivity : CallBaseActivity() { } private fun proceedToCall() { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled.capabilities?.spreedCapability)) { - Toast.makeText(context, R.string.nc_call_e2ee_not_supported, Toast.LENGTH_LONG).show() - hangup() - return - } val callIntent = Intent(this, CallActivity::class.java) intent.putExtra(KEY_ROOM_ONE_TO_ONE, isOneToOneCall) callIntent.putExtras(intent.extras!!) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 7c6b0a0f0f6..2bae82a3959 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3484,10 +3484,6 @@ class ChatActivity : private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { currentConversation?.let { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(spreedCapabilities)) { - Snackbar.make(binding.root, R.string.nc_call_e2ee_not_supported, Snackbar.LENGTH_LONG).show() - return - } val pp = ParticipantPermissions(spreedCapabilities, it) if (!pp.canStartCall() && currentConversation?.hasCall == false) { Snackbar.make(binding.root, R.string.startCallForbidden, Snackbar.LENGTH_LONG).show() diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index f03d2abe101..42dfaf432d9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1206,10 +1206,6 @@ class ConversationsListActivity : BaseActivity() { val chatIntent = ChatActivity.createIntent(context, currentUser.id!!, it.token, bundle) - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(currentUser.capabilities?.spreedCapability)) { - showSnackbar(context.getString(R.string.nc_call_e2ee_not_supported)) - return@let - } val pp = ParticipantPermissions(currentUser.capabilities?.spreedCapability, it) if (!pp.canStartCall() && selectedConversation?.hasCall == false) { Log.e(TAG, "Error starting call from conversations list: call is forbidden") diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 56fba0998e4..e05a7398292 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -76,7 +76,6 @@ import com.nextcloud.talk.receivers.ShareRecordingToChatReceiver import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.DisplayUtils @@ -393,11 +392,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } if (conversation != null && userBeingCalled != null) { - if (CapabilitiesUtil.isCallEndToEndEncryptionEnabled(userBeingCalled.capabilities?.spreedCapability)) { - showEndToEndEncryptionUnsupportedNotification(conversation) - } else { - prepareCallNotificationScreen(conversation) - } + prepareCallNotificationScreen(conversation) } } @@ -1290,34 +1285,6 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } } - private fun showEndToEndEncryptionUnsupportedNotification(conversation: ConversationModel) { - val notificationBuilder = NotificationCompat.Builder( - context!!, - NotificationUtils.NotificationChannels - .NOTIFICATION_CHANNEL_MESSAGES_V4.name - ) - - val intent = createMainActivityIntent() - - val notification: Notification = notificationBuilder - .setContentTitle( - String.format( - context!!.resources.getString(R.string.nc_call_e2ee_not_supported_title), - conversation.displayName - ) - ) - .setContentText(context!!.resources.getString(R.string.nc_call_e2ee_not_supported)) - .setSmallIcon(R.drawable.ic_call_black_24dp) - .setOngoing(false) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setContentIntent(createUniquePendingIntent(intent)) - .build() - - sendNotification(pushMessage.timestamp.toInt(), notification) - Log.d(TAG, "'end-to-end-encryption not supported' notification was created for ${conversation.token}") - } - private fun createMainActivityIntent(): Intent { val intent = Intent(context, MainActivity::class.java) intent.flags = getIntentFlags() diff --git a/app/src/main/java/com/nextcloud/talk/models/json/AnyParceler.kt b/app/src/main/java/com/nextcloud/talk/models/json/AnyParceler.kt index aa59d2ca1a7..68a0fa08dd6 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/AnyParceler.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/AnyParceler.kt @@ -13,6 +13,6 @@ class AnyParceler : Parceler { override fun create(parcel: Parcel): Any? = parcel.readValue(Any::class.java.getClassLoader()) override fun Any?.write(parcel: Parcel, flags: Int) { - parcel.writeValue(parcel) + parcel.writeValue(this) } } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/signaling/EncryptionKeyConverter.kt b/app/src/main/java/com/nextcloud/talk/models/json/signaling/EncryptionKeyConverter.kt new file mode 100644 index 00000000000..f3672a424eb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/signaling/EncryptionKeyConverter.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.signaling + +import com.bluelinelabs.logansquare.LoganSquare +import com.bluelinelabs.logansquare.typeconverters.TypeConverter +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken + +/** + * The "key" of a key exchange payload: a String in "encryption.start" messages, an object with "type" and "body" in + * the other ones. LoganSquare writes an untyped field without its name, so this converter handles both directions. + */ +class EncryptionKeyConverter : TypeConverter { + override fun parse(jsonParser: JsonParser): Any? = + when (jsonParser.currentToken) { + JsonToken.VALUE_STRING -> jsonParser.text + JsonToken.START_OBJECT -> LoganSquare.mapperFor(Any::class.java).parse(jsonParser) + else -> { + jsonParser.skipChildren() + null + } + } + + override fun serialize( + value: Any?, + fieldName: String, + writeFieldNameForObject: Boolean, + jsonGenerator: JsonGenerator + ) { + if (value == null) { + return + } + if (writeFieldNameForObject) { + jsonGenerator.writeFieldName(fieldName) + } + if (value is String) { + jsonGenerator.writeString(value) + } else { + LoganSquare.mapperFor(Any::class.java).serialize(value, jsonGenerator, true) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/signaling/FrameKeyDto.kt b/app/src/main/java/com/nextcloud/talk/models/json/signaling/FrameKeyDto.kt new file mode 100644 index 00000000000..1f293956060 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/signaling/FrameKeyDto.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.signaling + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +/** + * The frame key as it travels inside an Olm message of the key exchange, like the web client sends it: the key + * material in base64 and the index it is stored at. + */ +@JsonObject +data class FrameKeyDto( + @JsonField(name = ["key"]) + var key: String? = null, + @JsonField(name = ["index"]) + var index: Int? = null +) { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt b/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt index 18ad4143be8..e962b1059f1 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt @@ -10,10 +10,13 @@ package com.nextcloud.talk.models.json.signaling import android.os.Parcelable import com.bluelinelabs.logansquare.annotation.JsonField import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.AnyParceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler @Parcelize @JsonObject +@TypeParceler data class NCMessagePayloadDto( @JsonField(name = ["type"]) var type: String? = null, @@ -30,8 +33,18 @@ data class NCMessagePayloadDto( @JsonField(name = ["timestamp"]) var timestamp: Long? = null, @JsonField(name = ["reaction"]) - var reaction: String? = null + var reaction: String? = null, + // Key exchange of end-to-end encrypted calls, see EncryptionMessage + @JsonField(name = ["id"]) + var id: String? = null, + @JsonField(name = ["identity"]) + var identity: String? = null, + /** String in "encryption.start" messages, Map with "type" and "body" in the other encryption messages. */ + @JsonField(name = ["key"], typeConverter = EncryptionKeyConverter::class) + var key: Any? = null, + @JsonField(name = ["error"]) + var error: String? = null ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null, null, null, null, null, null, null, null) + constructor() : this(null, null, null, null, null, null, null, null, null, null, null, null) } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/EncryptionMessageNotifier.kt b/app/src/main/java/com/nextcloud/talk/signaling/EncryptionMessageNotifier.kt new file mode 100644 index 00000000000..5900219075b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/signaling/EncryptionMessageNotifier.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.signaling + +import com.nextcloud.talk.call.e2ee.EncryptionMessage + +/** + * Helper class to register and notify EncryptionMessageListeners. + * + * This class is only meant for internal use by SignalingMessageReceiver; listeners must register themselves against + * a SignalingMessageReceiver rather than against an EncryptionMessageNotifier. + */ +internal class EncryptionMessageNotifier { + private val listeners = LinkedHashSet() + + @Synchronized + fun addListener(listener: SignalingMessageReceiver.EncryptionMessageListener?) { + requireNotNull(listener) { "EncryptionMessageListener can not be null" } + listeners.add(listener) + } + + @Synchronized + fun removeListener(listener: SignalingMessageReceiver.EncryptionMessageListener?) { + listeners.remove(listener) + } + + @Synchronized + fun notifyEncryptionMessage(sessionId: String, message: EncryptionMessage) { + for (listener in listeners.toList()) { + listener.onEncryptionMessage(sessionId, message) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt index 4036deec18f..26b64ff6c0c 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.signaling import com.bluelinelabs.logansquare.LoganSquare import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.call.e2ee.EncryptionMessage import com.nextcloud.talk.models.json.chat.ChatMessageDto import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import com.nextcloud.talk.models.json.converters.EnumParticipantTypeConverter @@ -59,6 +60,8 @@ abstract class SignalingMessageReceiver { private val webRtcMessageNotifier = WebRtcMessageNotifier() + private val encryptionMessageNotifier = EncryptionMessageNotifier() + /** * Listener for participant list messages. * @@ -201,6 +204,15 @@ abstract class SignalingMessageReceiver { fun onEndOfCandidates() } + /** + * Listener for the key exchange messages of end-to-end encrypted calls. + * + * These messages are not passed to any other listener. + */ + fun interface EncryptionMessageListener { + fun onEncryptionMessage(sessionId: String, message: EncryptionMessage) + } + /** * Adds a listener for participant list messages. * @@ -294,6 +306,14 @@ abstract class SignalingMessageReceiver { webRtcMessageNotifier.removeListener(listener) } + fun addListener(listener: EncryptionMessageListener?) { + encryptionMessageNotifier.addListener(listener) + } + + fun removeListener(listener: EncryptionMessageListener?) { + encryptionMessageNotifier.removeListener(listener) + } + fun processEvent(eventMap: Map?) { if ("room" == eventMap?.get("target") && "switchto" == eventMap["type"]) { processSwitchToEvent(eventMap) @@ -653,6 +673,41 @@ abstract class SignalingMessageReceiver { val sessionId = signalingMessage.from val roomType = signalingMessage.roomType + if ("message" == type) { + // Message schema (external signaling server only, the key exchange needs the MCU): + // { + // "type": "message", + // "message": { + // "sender": { + // ... + // }, + // "data": { + // "to": #STRING#, + // "type": "message", + // "payload": { + // "type": "encryption.start" | "encryption.finish" | "encryption.setkey" | + // "encryption.gotkey" | "encryption.error", + // "id": #STRING#, + // "identity": #STRING#, + // "key": #STRING# | { "type": #INTEGER#, "body": #STRING# }, + // "error": #STRING#, + // }, + // "from": #STRING#, + // }, + // }, + // } + + val payload = signalingMessage.payload ?: return + val message = EncryptionMessage.fromPayload(payload) ?: return + if (sessionId == null) { + return + } + + encryptionMessageNotifier.notifyEncryptionMessage(sessionId, message) + + return + } + if ("raiseHand" == type) { // Message schema (external signaling server): // { diff --git a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt index 628e4fa9406..ce634e6c0ff 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -72,7 +72,8 @@ enum class SpreedFeatures(val value: String) { ANNOUNCEMENT_PRESET("announcement-preset"), CONVERSATION_TAGS("conversation-tags"), PROMOTE_DEMOTE_OWNER("promote-demote-owner"), - NOTE_TO_SELF("note-to-self") + NOTE_TO_SELF("note-to-self"), + CALL_END_TO_END_ENCRYPTION("call-end-to-end-encryption") } @Suppress("TooManyFunctions") @@ -339,8 +340,12 @@ object CapabilitiesUtil { fun isBanningAvailable(spreedCapabilities: SpreedCapabilityDto): Boolean = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.BAN_V1) + /** + * Whether calls have to be end-to-end encrypted: the signaling setup supports it and the admin turned it on. + */ fun isCallEndToEndEncryptionEnabled(spreedCapabilities: SpreedCapabilityDto?): Boolean { if ( + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.CALL_END_TO_END_ENCRYPTION) && spreedCapabilities?.config?.containsKey("call") == true && spreedCapabilities.config!!["call"] != null && spreedCapabilities.config!!["call"]!!.containsKey("end-to-end-encryption") diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycle.kt b/app/src/main/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycle.kt new file mode 100644 index 00000000000..1af4007f089 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycle.kt @@ -0,0 +1,77 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import android.util.Log +import com.nextcloud.talk.call.e2ee.CallEncryption +import com.nextcloud.talk.call.e2ee.CallEncryptionFactory +import com.nextcloud.talk.call.e2ee.EncryptionMessage + +/** + * Owns the key exchange of a signaling session: one per joined room and session, like the web client. It is created + * when a room is joined while calls have to be encrypted and the MCU is there, and closed when the room or the session + * is gone. + * + * @param isEnabled whether calls have to be end-to-end encrypted, read at every room join + * @param create builds the key exchange, null when the native libraries can not be used + */ +class CallEncryptionLifecycle( + private val isEnabled: () -> Boolean, + private val create: (ownSessionId: String, send: (String, EncryptionMessage) -> Unit) -> CallEncryption? = + CallEncryptionFactory::create +) { + /** The key exchange of the joined room, null while there is none. */ + @Volatile + var current: CallEncryption? = null + private set + + /** + * @param alreadyInRoom whether the same room was joined with the same session already, the exchange is kept then + */ + fun roomJoined( + ownSessionId: String?, + hasMCU: Boolean, + alreadyInRoom: Boolean, + send: (String, EncryptionMessage) -> Unit + ) { + if (!alreadyInRoom) { + close() + } + if (!isEnabled() || !hasMCU || ownSessionId == null || current != null) { + return + } + current = create(ownSessionId, send) + Log.d(TAG, "Key exchange ${if (current != null) "created" else "not available"} for session $ownSessionId") + } + + /** The room or the signaling session is gone, and the keys with it. */ + fun close() { + current?.close() + current = null + } + + fun usersJoined(sessionIds: List) { + current?.usersJoined(sessionIds) + } + + fun usersLeft(sessionIds: List) { + current?.usersLeft(sessionIds) + } + + fun handleMessage(sessionId: String, message: EncryptionMessage) { + val current = current + if (current != null) { + current.handleMessage(sessionId, message) + } else { + Log.w(TAG, "Dropped ${message.type} from $sessionId, no key exchange") + } + } + + private companion object { + private val TAG = CallEncryptionLifecycle::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java index 295c1250085..10a755c8b7b 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -10,6 +10,7 @@ import android.util.Log; import com.bluelinelabs.logansquare.LoganSquare; +import com.nextcloud.talk.call.e2ee.FrameKeyRing; import com.nextcloud.talk.models.json.signaling.DataChannelMessageDto; import com.nextcloud.talk.models.json.signaling.NCIceCandidateDto; import com.nextcloud.talk.models.json.signaling.NCMessagePayloadDto; @@ -26,6 +27,7 @@ import org.webrtc.PeerConnection; import org.webrtc.PeerConnectionFactory; import org.webrtc.RtpReceiver; +import org.webrtc.RtpSender; import org.webrtc.RtpTransceiver; import org.webrtc.SessionDescription; import org.webrtc.VideoTrack; @@ -42,7 +44,7 @@ import androidx.annotation.Nullable; -public class PeerConnectionWrapper { +public final class PeerConnectionWrapper { private static final String TAG = PeerConnectionWrapper.class.getCanonicalName(); @@ -66,6 +68,12 @@ public class PeerConnectionWrapper { private final boolean isMCUPublisher; private final String videoStreamType; + // Keys of the remote participant when the call is end-to-end encrypted, attached to every receiver + @Nullable + private final FrameKeyRing receiverKeyRing; + // The frame encryption only handles VP8 + private final String preferredVideoCodec; + // It is assumed that there will be at most one remote stream at each time. private MediaStream stream; private volatile boolean remoteAudioPlayoutEnabled = false; @@ -106,14 +114,34 @@ public interface PeerConnectionObserver { void onIceConnectionStateChanged(PeerConnection.IceConnectionState iceConnectionState); } + /** + * @param encryptionRequired whether the call has to be end-to-end encrypted; the wrapper is then not created + * without the keys it needs, so no media is ever exchanged unencrypted + * @param senderKeyRing own keys to encrypt the local tracks with, null when the call is not end-to-end encrypted + * @param receiverKeyRing keys of the remote participant to decrypt its tracks with, null when the call is not + * end-to-end encrypted + * @throws IllegalStateException when encryption is required but the keys for this connection are missing + */ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, List iceServerList, MediaConstraints mediaConstraints, String sessionId, String localSession, @Nullable MediaStream localStream, boolean isMCUPublisher, boolean hasMCU, String videoStreamType, SignalingMessageReceiver signalingMessageReceiver, - SignalingMessageSender signalingMessageSender) { + SignalingMessageSender signalingMessageSender, + boolean encryptionRequired, + @Nullable FrameKeyRing senderKeyRing, + @Nullable FrameKeyRing receiverKeyRing) { + boolean missingSenderKeys = (isMCUPublisher || localStream != null) && senderKeyRing == null; + boolean missingReceiverKeys = !isMCUPublisher && receiverKeyRing == null; + if (encryptionRequired && (missingSenderKeys || missingReceiverKeys)) { + throw new IllegalStateException("The call has to be end-to-end encrypted but there are no keys for " + + sessionId); + } + this.videoStreamType = videoStreamType; + this.receiverKeyRing = receiverKeyRing; + this.preferredVideoCodec = senderKeyRing != null || receiverKeyRing != null ? "VP8" : "H264"; this.sessionId = sessionId; this.mediaConstraints = mediaConstraints; @@ -142,6 +170,17 @@ public PeerConnectionWrapper(PeerConnectionFactory peerConnectionFactory, } } + if (senderKeyRing != null) { + // Attached before any offer is created, so no frame is ever sent unencrypted + for (RtpSender sender : peerConnection.getSenders()) { + if (!senderKeyRing.attachEncryptor(sender)) { + // The own keys are disposed when the call ends, so nothing may be sent anymore + Log.w(TAG, "Own keys are gone, not sending with sender " + sender.id()); + peerConnection.removeTrack(sender); + } + } + } + if (hasMCU || hasInitiated) { DataChannel.Init init = new DataChannel.Init(); init.negotiated = false; @@ -453,7 +492,7 @@ private void onOfferOrAnswer(String type, String sdp) { SessionDescription sessionDescriptionWithPreferredCodec; boolean isAudio = false; - String sessionDescriptionStringWithPreferredCodec = WebRTCUtils.preferCodec(sdp, "H264", isAudio); + String sessionDescriptionStringWithPreferredCodec = WebRTCUtils.preferCodec(sdp, preferredVideoCodec, isAudio); sessionDescriptionWithPreferredCodec = new SessionDescription( SessionDescription.Type.fromCanonicalForm(type), @@ -706,6 +745,14 @@ public void onRenegotiationNeeded() { @Override public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) { + // Called on the signaling thread before the track is wired to a decoder, so no encrypted frame reaches + // it without a decryptor + if (receiverKeyRing != null && !receiverKeyRing.attachDecryptor(rtpReceiver)) { + // The participant left and its keys are disposed, so this connection is about to be closed too + Log.w(TAG, "Not adding a track of " + sessionId + " that can not be decrypted anymore"); + return; + } + MediaStreamTrack track = rtpReceiver.track(); synchronized (PeerConnectionWrapper.this) { if (track instanceof AudioTrack) { @@ -755,7 +802,7 @@ public void onCreateSuccess(SessionDescription sessionDescription) { SessionDescription sessionDescriptionWithPreferredCodec; String sessionDescriptionStringWithPreferredCodec = WebRTCUtils.preferCodec (sessionDescription.description, - "H264", false); + preferredVideoCodec, false); sessionDescriptionWithPreferredCodec = new SessionDescription( sessionDescription.type, sessionDescriptionStringWithPreferredCodec); diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java index 4a64637ab8b..7d65e8a403b 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java @@ -25,7 +25,7 @@ import com.nextcloud.talk.models.json.websocket.RoomWebSocketMessageDto; import com.nextcloud.talk.utils.ApiUtils; -import java.util.Collections; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -83,6 +83,7 @@ public static synchronized WebSocketInstance getExternalSignalingInstanceForServ WebSocketInstance webSocketInstance = getWebSocketInstanceForUser(user); if (webSocketInstance != null) { Log.d(TAG, "Reusing webSocketInstance " + webSocketInstance.hashCode() + " for userId " + userId); + webSocketInstance.refreshCallEncryptionPolicy(user); return webSocketInstance; } } @@ -104,7 +105,7 @@ public static synchronized void deleteExternalSignalingInstanceForUserEntity(lon } } - HelloOverallWebSocketMessage getAssembledHelloModel(User user, String ticket) { + HelloOverallWebSocketMessage getAssembledHelloModel(User user, String ticket, boolean encryption) { int apiVersion = ApiUtils.getSignalingApiVersion(user, new int[]{ApiUtils.API_V3, 2, 1}); HelloOverallWebSocketMessage helloOverallWebSocketMessage = new HelloOverallWebSocketMessage(); @@ -120,26 +121,36 @@ HelloOverallWebSocketMessage getAssembledHelloModel(User user, String ticket) { } authWebSocketMessage.setAuthParametersWebSocketMessage(authParametersWebSocketMessage); helloWebSocketMessage.setAuthWebSocketMessage(authWebSocketMessage); - - List features = Collections.singletonList("chat-relay"); - helloWebSocketMessage.setFeatures(features); + helloWebSocketMessage.setFeatures(getClientFeatures(encryption)); helloOverallWebSocketMessage.setHelloWebSocketMessage(helloWebSocketMessage); return helloOverallWebSocketMessage; } - HelloOverallWebSocketMessage getAssembledHelloModelForResume(String resumeId) { + HelloOverallWebSocketMessage getAssembledHelloModelForResume(String resumeId, boolean encryption) { HelloOverallWebSocketMessage helloOverallWebSocketMessage = new HelloOverallWebSocketMessage(); helloOverallWebSocketMessage.setType("hello"); HelloWebSocketMessageDto helloWebSocketMessage = new HelloWebSocketMessageDto(); helloWebSocketMessage.setVersion("1.0"); helloWebSocketMessage.setResumeid(resumeId); - List features = Collections.singletonList("chat-relay"); - helloWebSocketMessage.setFeatures(features); + helloWebSocketMessage.setFeatures(getClientFeatures(encryption)); helloOverallWebSocketMessage.setHelloWebSocketMessage(helloWebSocketMessage); return helloOverallWebSocketMessage; } + /** + * The features announced to the signaling server; "encryption" tells it and the SIP bridge that this client + * end-to-end encrypts its streams. + */ + private static List getClientFeatures(boolean encryption) { + List features = new ArrayList<>(); + features.add("chat-relay"); + if (encryption) { + features.add("encryption"); + } + return features; + } + RoomOverallWebSocketMessage getAssembledJoinOrLeaveRoomModel(String roomId, String sessionId, FederationSettingsDto federation) { RoomOverallWebSocketMessage roomOverallWebSocketMessage = new RoomOverallWebSocketMessage(); diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 9599505c9aa..b85b0b2c372 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -14,6 +14,8 @@ import autodagger.AutoInjector import com.bluelinelabs.logansquare.LoganSquare import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.call.e2ee.CallEncryption +import com.nextcloud.talk.call.e2ee.EncryptionMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.NetworkEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent @@ -31,6 +33,7 @@ import com.nextcloud.talk.models.json.websocket.HelloResponseOverallWebSocketMes import com.nextcloud.talk.models.json.websocket.JoinedRoomOverallWebSocketMessage import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageSender +import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.bundle.BundleKeys import okhttp3.OkHttpClient import okhttp3.Request @@ -83,6 +86,18 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU val signalingMessageSender = ExternalSignalingMessageSender() private val signalingHttpClient: OkHttpClient by lazy { createSignalingHttpClient(okHttpClient!!) } + // Whether calls have to be end-to-end encrypted, refreshed whenever this instance is handed out again + private var isCallEncryptionEnabled = + CapabilitiesUtil.isCallEndToEndEncryptionEnabled(conversationUser.capabilities?.spreedCapability) + + /** + * The key exchange of the joined room, null until the room is joined with the MCU or when calls are not + * end-to-end encrypted. It is replaced whenever the signaling session changes. + */ + private val callEncryptionLifecycle = CallEncryptionLifecycle({ isCallEncryptionEnabled }) + val callEncryption: CallEncryption? + get() = callEncryptionLifecycle.current + init { sharedApplication!!.componentApplication.inject(this) this.connectionUrl = connectionUrl @@ -91,6 +106,9 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU webSocketConnectionHelper = WebSocketConnectionHelper() usersHashMap = HashMap() isConnected = false + signalingMessageReceiver.addListener( + SignalingMessageReceiver.EncryptionMessageListener(callEncryptionLifecycle::handleMessage) + ) eventBus!!.register(this) restartWebSocket() } @@ -101,14 +119,14 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU internalWebSocket!!.send( LoganSquare.serialize( webSocketConnectionHelper - .getAssembledHelloModel(conversationUser, webSocketTicket) + .getAssembledHelloModel(conversationUser, webSocketTicket, isCallEncryptionEnabled) ) ) } else { internalWebSocket!!.send( LoganSquare.serialize( webSocketConnectionHelper - .getAssembledHelloModelForResume(resumeId) + .getAssembledHelloModelForResume(resumeId, isCallEncryptionEnabled) ) ) } @@ -187,15 +205,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU private fun processMessage(text: String) { val (_, callWebSocketMessage) = LoganSquare.parse(text, CallOverallWebSocketMessage::class.java) if (callWebSocketMessage != null) { - val ncSignalingMessage = callWebSocketMessage.ncSignalingMessage - - if (ncSignalingMessage != null && - TextUtils.isEmpty(ncSignalingMessage.from) && - callWebSocketMessage.senderWebSocketMessage != null - ) { - ncSignalingMessage.from = callWebSocketMessage.senderWebSocketMessage!!.sessionId - } - + takeSenderFromEnvelope(callWebSocketMessage) signalingMessageReceiver.processChatMessage(callWebSocketMessage) } } @@ -283,6 +293,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } usersHashMap[internalHashMap["sessionid"] as String?] = participant } + callEncryptionLifecycle.usersJoined(joinEventList.mapNotNull { it["sessionid"] as String? }) } private fun processRoomLeaveMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) { @@ -290,6 +301,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU for (i in leaveEventList!!.indices) { usersHashMap.remove(leaveEventList[i]) } + callEncryptionLifecycle.usersLeft(leaveEventList) } fun getUserMap(): HashMap = usersHashMap @@ -300,7 +312,11 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU if (roomWebSocketMessage != null) { val alreadyInRoom = roomWebSocketMessage.roomId == currentRoomToken currentRoomToken = roomWebSocketMessage.roomId - if (roomWebSocketMessage.roomPropertiesWebSocketMessage != null && !TextUtils.isEmpty(currentRoomToken)) { + if (TextUtils.isEmpty(currentRoomToken)) { + // The server took the client out of the room, so the keys of that room go with it, like on iOS + callEncryptionLifecycle.close() + } else if (roomWebSocketMessage.roomPropertiesWebSocketMessage != null) { + callEncryptionLifecycle.roomJoined(sessionId, hasMCU, alreadyInRoom, ::sendEncryptionMessage) if (alreadyInRoom) { sendRoomUpdatedEvent() } else { @@ -310,6 +326,34 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } } + // The capabilities may have changed since this instance was created. Once calls have to be encrypted while a + // room is joined (or about to be resumed), a new signaling session is needed: the key exchange is set up when + // the room is joined, and the other participants only start their handshakes when they see that join + fun refreshCallEncryptionPolicy(user: User) { + val enabled = CapabilitiesUtil.isCallEndToEndEncryptionEnabled(user.capabilities?.spreedCapability) + if (enabled && !isCallEncryptionEnabled && !currentRoomToken.isNullOrEmpty()) { + Log.d(TAG, "Calls have to be end-to-end encrypted now, starting a new signaling session") + startNewSignalingSession() + } + isCallEncryptionEnabled = enabled + } + + // Drops the session and the joined room, so the next hello starts a new session and the room is joined again + private fun startNewSignalingSession() { + resumeId = "" + currentRoomToken = "" + currentNormalBackendSession = "" + restartWebSocket() + } + + private fun sendEncryptionMessage(to: String, message: EncryptionMessage) { + val ncSignalingMessage = NCSignalingMessageDto() + ncSignalingMessage.to = to + ncSignalingMessage.type = "message" + ncSignalingMessage.payload = message.toPayload() + sendCallMessage(ncSignalingMessage) + } + @Throws(IOException::class) private fun processErrorMessage(webSocket: WebSocket, text: String) { Log.e(TAG, "Received error: $text") @@ -317,10 +361,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU if (message != null) { if ("no_such_session" == message.code) { Log.d(TAG, "WebSocket " + webSocket.hashCode() + " resumeID " + resumeId + " expired") - resumeId = "" - currentRoomToken = "" - currentNormalBackendSession = "" - restartWebSocket() + startNewSignalingSession() } else if ("hello_expected" == message.code) { restartWebSocket() } else if ("no_such_room" == message.code) { @@ -345,6 +386,10 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU ) if (helloResponseWebSocketMessage1 != null) { resumeId = helloResponseWebSocketMessage1.resumeId + if (sessionId != helloResponseWebSocketMessage1.sessionId) { + // A new session has to exchange keys again after joining the room again + callEncryptionLifecycle.close() + } sessionId = helloResponseWebSocketMessage1.sessionId hasMCU = helloResponseWebSocketMessage1.serverHasMCUSupport() @@ -425,6 +470,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU Log.d(TAG, "sending 'leave room' via websocket") currentNormalBackendSession = "" currentFederation = null + callEncryptionLifecycle.close() sendMessage(message) } else if ( roomToken == currentRoomToken && @@ -472,6 +518,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } fun sendBye() { + callEncryptionLifecycle.close() if (isConnected) { try { val byeWebSocketMessage = ByeWebSocketMessageDto() @@ -546,6 +593,12 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU private const val ONE_SECOND: Long = 1000 private const val PING_INTERVAL_SECONDS: Long = 30 + // The sender is what the signaling server says, never what the message claims, like in the web client. A + // message without a sender gets none, so the key exchange ignores it + internal fun takeSenderFromEnvelope(callWebSocketMessage: CallWebSocketMessageDto) { + callWebSocketMessage.ncSignalingMessage?.from = callWebSocketMessage.senderWebSocketMessage?.sessionId + } + // Dedicated client with pings, so half-open WebSocket connections // (e.g. after a WiFi to cellular switch without TCP reset) fail and trigger the reconnect path. internal fun createSignalingHttpClient(baseClient: OkHttpClient): OkHttpClient = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ef9f26e226..b7bde7c2ab9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -651,8 +651,8 @@ How to translate with transifex: Keep Message deleted successfully, but it might have been leaked to other services You are not allowed to start a call - Call from %s unavailable - Calling is currently not supported because end-to-end encryption is enabled on the server + End-to-end encrypted calls require the high performance backend (HPB) + End-to-end encryption could not be set up for this call You need to promote a new moderator before you can leave the conversation Room is retained successfully diff --git a/app/src/test/java/com/nextcloud/talk/call/e2ee/CallEncryptionTest.kt b/app/src/test/java/com/nextcloud/talk/call/e2ee/CallEncryptionTest.kt new file mode 100644 index 00000000000..5f315a36d4f --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/e2ee/CallEncryptionTest.kt @@ -0,0 +1,394 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call.e2ee + +import com.bluelinelabs.logansquare.LoganSquare +import com.nextcloud.talk.models.json.signaling.FrameKeyDto +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender + +/** + * Two CallEncryption instances exchange keys through a fake signaling connection, with a fake Olm that only + * tracks message kinds and a fake key ring that records the keys set on it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CallEncryptionTest { + + private class Message(val from: String, val to: String, val message: EncryptionMessage) { + val keyType: Int? get() = message.olmKey?.type + } + + // Delivers messages between the instances and records them, through the payload DTO like on the wire + private class Signaling { + private val participants = HashMap() + val messages = ArrayList() + + fun sender(sessionId: String): (String, EncryptionMessage) -> Unit = + { recipient, message -> + val wireMessage = EncryptionMessage.fromPayload(message.toPayload())!! + messages.add(Message(sessionId, recipient, wireMessage)) + participants[recipient]?.handleMessage(sessionId, wireMessage) + } + + fun add(sessionId: String, callEncryption: CallEncryption) { + participants[sessionId] = callEncryption + } + + fun sent(type: String, from: String): List = + messages.filter { + it.message.type == type && + it.from == from + } + } + + private class FakeOlmSession(private var nextType: Int) : OlmSession { + override fun encrypt(plaintext: String): OlmMessage = + OlmMessage(nextType, plaintext).also { nextType = OlmMessage.TYPE_NORMAL } + + override fun decrypt(message: OlmMessage): String { + nextType = OlmMessage.TYPE_NORMAL + return message.body + } + + override fun close() { + // Not needed in tests + } + } + + // The side creating the outbound session sends a pre-key message first, everything else is normal + private class FakeOlm(override val identityKey: String) : OlmAccount { + override fun createOneTimeKey(): String = "otk-$identityKey" + + override fun createOutboundSession(theirIdentityKey: String, theirOneTimeKey: String): OlmSession { + require(theirOneTimeKey == "otk-$theirIdentityKey") { "Invalid one-time key" } + return FakeOlmSession(OlmMessage.TYPE_PRE_KEY) + } + + override fun createInboundSession(preKeyMessage: OlmMessage): Pair { + require(preKeyMessage.type == OlmMessage.TYPE_PRE_KEY) { "Not a pre-key message" } + return FakeOlmSession(OlmMessage.TYPE_NORMAL) to preKeyMessage.body + } + + override fun close() { + // Not needed in tests + } + } + + private class FakeKeyRing : FrameKeyRing { + /** Copies, like the native key ring keeps them. */ + val keys = ArrayList>() + + /** The arrays as passed in, to observe that superseded key material is wiped. */ + val keyRefs = ArrayList() + var disposed = false + + override fun setKey(key: ByteArray, keyIndex: Int): Boolean { + keys.add(key.copyOf() to keyIndex) + keyRefs.add(key) + return !disposed && key.isNotEmpty() + } + + override fun attachEncryptor(sender: RtpSender): Boolean = !disposed + + override fun attachDecryptor(receiver: RtpReceiver): Boolean = !disposed + + override fun dispose() { + disposed = true + } + } + + private class FakeFrameCrypto : FrameCrypto { + val keyRings = ArrayList() + + override fun createKeyRing(): FrameKeyRing = FakeKeyRing().also { keyRings.add(it) } + + override fun ratchetKey(key: ByteArray): ByteArray = key.map { (it + 1).toByte() }.toByteArray() + } + + // "session-b" sorts after "session-a", so it starts the Olm session + private val higherSessionId = "session-b" + private val lowerSessionId = "session-a" + + private val signaling = Signaling() + private val frameCrypto = FakeFrameCrypto() + + private fun TestScope.callEncryption(sessionId: String): CallEncryption = + CallEncryption( + ownSessionId = sessionId, + olm = FakeOlm(sessionId), + frameCrypto = frameCrypto, + sendMessage = signaling.sender(sessionId), + dispatcher = StandardTestDispatcher(testScheduler), + periodMs = PERIOD_MS + ).also { signaling.add(sessionId, it) } + + private fun TestScope.exchangeKeys(higher: CallEncryption, lower: CallEncryption) { + // Both see the same join event, including their own session + val joined = listOf(lowerSessionId, higherSessionId) + higher.usersJoined(joined) + lower.usersJoined(joined) + advanceUntilIdle() + } + + private fun ownKeyRing(callEncryption: CallEncryption): FakeKeyRing = callEncryption.ownKeyRing as FakeKeyRing + + @Test + fun higherSessionStartsAndKeysAreExchanged() = + runTest { + val higher = callEncryption(higherSessionId) + val lower = callEncryption(lowerSessionId) + + exchangeKeys(higher, lower) + + assertEquals( + listOf( + EncryptionMessage.START to higherSessionId, + EncryptionMessage.FINISH to lowerSessionId, + EncryptionMessage.SET_KEY to higherSessionId, + EncryptionMessage.GOT_KEY to lowerSessionId + ), + signaling.messages.map { it.message.type to it.from } + ) + assertTrue(signaling.sent(EncryptionMessage.START, lowerSessionId).isEmpty()) + assertTrue(signaling.sent(EncryptionMessage.ERROR, lowerSessionId).isEmpty()) + assertTrue(signaling.sent(EncryptionMessage.ERROR, higherSessionId).isEmpty()) + + // Each side stored the other side's key at index 0 + val higherKey = ownKeyRing(higher).keys.first() + val lowerKey = ownKeyRing(lower).keys.first() + assertEquals(0, higherKey.second) + assertEquals(0, lowerKey.second) + assertEquals( + higherKey.first.toList(), + (lower.keyRing(higherSessionId) as FakeKeyRing).keys.single().first.toList() + ) + assertEquals( + lowerKey.first.toList(), + (higher.keyRing(lowerSessionId) as FakeKeyRing).keys.single().first.toList() + ) + assertNotEquals(higherKey.first.toList(), lowerKey.first.toList()) + } + + @Test + fun messagesHaveWebClientFormat() = + runTest { + val higher = callEncryption(higherSessionId) + val lower = callEncryption(lowerSessionId) + + exchangeKeys(higher, lower) + + val start = signaling.sent(EncryptionMessage.START, higherSessionId).first() + assertNotNull(start.message.id) + assertEquals(higherSessionId, start.message.identity) + assertEquals("otk-$higherSessionId", start.message.key) + + // The side answering the start creates the outbound Olm session, so its first message is a pre-key + // message, everything after the first reply is a normal one + val finish = signaling.sent(EncryptionMessage.FINISH, lowerSessionId).first() + assertEquals(start.message.id, finish.message.id) + assertEquals(OlmMessage.TYPE_PRE_KEY, finish.keyType) + + val setKey = signaling.sent(EncryptionMessage.SET_KEY, higherSessionId).first() + assertEquals(OlmMessage.TYPE_NORMAL, setKey.keyType) + // 32 bytes of key material in base64 and the index + val sentKey = LoganSquare.parse(setKey.message.olmKey!!.body, FrameKeyDto::class.java) + assertEquals(44, sentKey.key!!.length) + assertEquals(0, sentKey.index) + + val gotKey = signaling.sent(EncryptionMessage.GOT_KEY, lowerSessionId).first() + assertEquals(setKey.message.id, gotKey.message.id) + assertEquals(OlmMessage.TYPE_NORMAL, gotKey.keyType) + } + + @Test + fun distributesNewKeyWhenSomeoneLeaves() = + runTest { + val higher = callEncryption(higherSessionId) + val lower = callEncryption(lowerSessionId) + exchangeKeys(higher, lower) + val keysBefore = ownKeyRing(higher).keys.size + + higher.usersLeft(listOf("session-c")) + advanceTimeBy(PERIOD_MS - 1) + assertEquals(1, signaling.sent(EncryptionMessage.SET_KEY, higherSessionId).size) + + advanceUntilIdle() + + val setKeys = signaling.sent(EncryptionMessage.SET_KEY, higherSessionId) + assertEquals(2, setKeys.size) + assertEquals(2, signaling.sent(EncryptionMessage.GOT_KEY, lowerSessionId).size) + assertEquals(1, LoganSquare.parse(setKeys.last().message.olmKey!!.body, FrameKeyDto::class.java).index) + + // The new key is only used once the recipient confirmed it, at the next index + val ownKeys = ownKeyRing(higher).keys + assertEquals(keysBefore + 1, ownKeys.size) + assertEquals(1, ownKeys.last().second) + assertEquals(1, (lower.keyRing(higherSessionId) as FakeKeyRing).keys.last().second) + + // Superseded key material is wiped, the key rings keep their own copies + assertTrue(ownKeyRing(higher).keyRefs.first().all { it == 0.toByte() }) + assertTrue((lower.keyRing(higherSessionId) as FakeKeyRing).keyRefs.first().all { it == 0.toByte() }) + assertFalse(ownKeys.first().first.all { it == 0.toByte() }) + } + + @Test + fun ratchetsWithoutDistributingKeyWhenSomeoneJoins() = + runTest { + val higher = callEncryption(higherSessionId) + val lower = callEncryption(lowerSessionId) + exchangeKeys(higher, lower) + val messagesBefore = signaling.messages.size + val ownKeys = ownKeyRing(higher).keys + val keysBefore = ownKeys.size + + // "session-c" sorts last, so it would start the session with us + higher.usersJoined(listOf("session-c")) + advanceUntilIdle() + + assertEquals(messagesBefore, signaling.messages.size) + assertEquals(keysBefore + 1, ownKeys.size) + assertEquals(0, ownKeys.last().second) + assertEquals(frameCrypto.ratchetKey(ownKeys[keysBefore - 1].first).toList(), ownKeys.last().first.toList()) + } + + @Test + fun answersKeyWithoutSessionWithError() = + runTest { + val higher = callEncryption(higherSessionId) + + higher.handleMessage( + "session-unknown", + EncryptionMessage(EncryptionMessage.SET_KEY, id = "1", key = mapOf("type" to 1, "body" to "invalid")) + ) + advanceUntilIdle() + + val error = signaling.sent(EncryptionMessage.ERROR, higherSessionId).single() + assertEquals("session-unknown", error.to) + assertEquals("No session for setting key", error.message.error) + } + + @Test + fun startTimesOutAndCanBeRetried() = + runTest { + val higher = callEncryption(higherSessionId) + + higher.usersJoined(listOf(lowerSessionId)) + advanceTimeBy(PERIOD_MS + 1) + higher.usersJoined(listOf(lowerSessionId)) + advanceUntilIdle() + + assertEquals(2, signaling.sent(EncryptionMessage.START, higherSessionId).size) + } + + @Test + fun oldStartTimeoutDoesNotClearRejoinedSession() = + runTest { + val higher = callEncryption(higherSessionId) + + higher.usersJoined(listOf(lowerSessionId)) + advanceTimeBy(PERIOD_MS / 2) + higher.usersLeft(listOf(lowerSessionId)) + higher.usersJoined(listOf(lowerSessionId)) + advanceTimeBy(PERIOD_MS / 2 + 1) + // The second start is still pending, so no third one is sent + higher.usersJoined(listOf(lowerSessionId)) + advanceUntilIdle() + + assertEquals(2, signaling.sent(EncryptionMessage.START, higherSessionId).size) + } + + @Test + fun finishAfterTimeoutIsRejected() = + runTest { + val higher = callEncryption(higherSessionId) + + higher.usersJoined(listOf(lowerSessionId)) + advanceUntilIdle() + val startId = signaling.sent(EncryptionMessage.START, higherSessionId).single().message.id + higher.handleMessage( + lowerSessionId, + EncryptionMessage(EncryptionMessage.FINISH, id = startId, key = mapOf("type" to 0, "body" to "late")) + ) + advanceUntilIdle() + + val error = signaling.sent(EncryptionMessage.ERROR, higherSessionId).single().message.error + assertEquals("Finish has wrong id", error) + assertTrue(signaling.sent(EncryptionMessage.SET_KEY, higherSessionId).isEmpty()) + } + + @Test + fun disposesKeyRingOfLeavingSession() = + runTest { + val higher = callEncryption(higherSessionId) + val lower = callEncryption(lowerSessionId) + exchangeKeys(higher, lower) + val remoteKeyRing = higher.keyRing(lowerSessionId) as FakeKeyRing + + higher.usersLeft(listOf(lowerSessionId)) + advanceUntilIdle() + + assertTrue(remoteKeyRing.disposed) + assertFalse(ownKeyRing(higher).disposed) + } + + @Test + fun releasesKeyRingsOnClose() = + runTest { + val higher = callEncryption(higherSessionId) + val lower = callEncryption(lowerSessionId) + exchangeKeys(higher, lower) + val remoteKeyRing = higher.keyRing(lowerSessionId) as FakeKeyRing + + higher.usersLeft(listOf("session-c")) + higher.close() + advanceUntilIdle() + + assertTrue(remoteKeyRing.disposed) + assertTrue(ownKeyRing(higher).disposed) + // No key ring is handed out anymore, so no connection can be created for the closed exchange + assertNull(higher.keyRing("session-d")) + // The rotation was cancelled by close + assertEquals(1, signaling.sent(EncryptionMessage.SET_KEY, higherSessionId).size) + assertFalse(ownKeyRing(lower).disposed) + } + + @Test + fun keyRingIsKeptPerSession() = + runTest { + val higher = callEncryption(higherSessionId) + + assertSame(higher.keyRing(lowerSessionId), higher.keyRing(lowerSessionId)) + assertNotSame(higher.keyRing(lowerSessionId), higher.keyRing("session-c")) + } + + @Test + fun recognizesEncryptionMessages() { + assertTrue(EncryptionMessage.isEncryptionMessage("encryption.start")) + assertTrue(EncryptionMessage.isEncryptionMessage("encryption.gotkey")) + assertFalse(EncryptionMessage.isEncryptionMessage("offer")) + assertFalse(EncryptionMessage.isEncryptionMessage(null)) + } + + companion object { + private const val PERIOD_MS = 100L + } +} diff --git a/app/src/test/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDtoEncryptionTest.kt b/app/src/test/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDtoEncryptionTest.kt new file mode 100644 index 00000000000..3256138b516 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDtoEncryptionTest.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.signaling + +import com.bluelinelabs.logansquare.LoganSquare +import com.nextcloud.talk.call.e2ee.EncryptionMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The key exchange payloads as the web client sends them survive LoganSquare in both directions. + */ +class NCMessagePayloadDtoEncryptionTest { + + @Test + fun parsesStartMessage() { + val payload = LoganSquare.parse( + """{"id":"abc","type":"encryption.start","identity":"idKey","key":"oneTimeKey"}""", + NCMessagePayloadDto::class.java + ) + + val message = EncryptionMessage.fromPayload(payload)!! + assertEquals("encryption.start", message.type) + assertEquals("abc", message.id) + assertEquals("idKey", message.identity) + assertEquals("oneTimeKey", message.key) + } + + @Test + fun parsesOlmMessage() { + val payload = LoganSquare.parse( + """{"id":"abc","type":"encryption.finish","key":{"type":0,"body":"AwoQ"}}""", + NCMessagePayloadDto::class.java + ) + + val olmKey = EncryptionMessage.fromPayload(payload)!!.olmKey!! + assertEquals(0, olmKey.type) + assertEquals("AwoQ", olmKey.body) + } + + private fun roundTrip(message: EncryptionMessage): Pair { + val json = LoganSquare.serialize(message.toPayload()) + return json to EncryptionMessage.fromPayload(LoganSquare.parse(json, NCMessagePayloadDto::class.java))!! + } + + @Test + fun serializesStartMessage() { + val start = EncryptionMessage("encryption.start", id = "abc", identity = "idKey", key = "oneTimeKey") + + val (json, parsed) = roundTrip(start) + + assertTrue(json, json.contains(""""key":"oneTimeKey"""")) + assertEquals(start, parsed) + } + + @Test + fun serializesOlmMessage() { + val setKey = EncryptionMessage("encryption.setkey", id = "abc", key = mapOf("type" to 1, "body" to "AwoQ")) + + val (json, parsed) = roundTrip(setKey) + + assertTrue(json, json.contains(""""body":"AwoQ"""") && json.contains(""""type":1""")) + assertEquals("abc", parsed.id) + assertEquals(setKey.olmKey, parsed.olmKey) + } + + @Test + fun serializesErrorMessage() { + val error = EncryptionMessage("encryption.error", error = "Finish has wrong id") + + val (json, parsed) = roundTrip(error) + + assertEquals("""{"error":"Finish has wrong id","type":"encryption.error"}""", json) + assertEquals(error, parsed) + } + + @Test + fun otherPayloadsAreNotEncryptionMessages() { + val payload = LoganSquare.parse("""{"type":"offer","sdp":"v=0"}""", NCMessagePayloadDto::class.java) + + assertFalse(EncryptionMessage.isEncryptionMessage(payload.type)) + assertEquals(null, EncryptionMessage.fromPayload(payload)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverEncryptionTest.kt b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverEncryptionTest.kt new file mode 100644 index 00000000000..855f05722d8 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverEncryptionTest.kt @@ -0,0 +1,122 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.signaling + +import com.nextcloud.talk.call.e2ee.EncryptionMessage +import com.nextcloud.talk.models.json.signaling.NCMessagePayloadDto +import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions + +class SignalingMessageReceiverEncryptionTest { + + private lateinit var signalingMessageReceiver: SignalingMessageReceiver + + @Before + fun setUp() { + // SignalingMessageReceiver is abstract to prevent direct instantiation without calling the appropriate + // protected methods. + signalingMessageReceiver = object : SignalingMessageReceiver() {} + } + + private fun signalingMessage(from: String?, payloadType: String?): NCSignalingMessageDto = + NCSignalingMessageDto().apply { + this.from = from + type = "message" + payload = NCMessagePayloadDto().apply { + type = payloadType + id = "theId" + identity = "theIdentity" + key = mapOf("type" to 1, "body" to "theBody") + error = "theError" + } + } + + @Test + fun testAddEncryptionMessageListenerWithNullListener() { + assertThrows(IllegalArgumentException::class.java) { + signalingMessageReceiver.addListener(null as SignalingMessageReceiver.EncryptionMessageListener?) + } + } + + @Test + fun testEncryptionMessage() { + val listener = mock(SignalingMessageReceiver.EncryptionMessageListener::class.java) + signalingMessageReceiver.addListener(listener) + + signalingMessageReceiver.processSignalingMessage(signalingMessage("theSessionId", "encryption.setkey")) + + verify(listener).onEncryptionMessage( + "theSessionId", + EncryptionMessage( + "encryption.setkey", + id = "theId", + identity = "theIdentity", + key = mapOf("type" to 1, "body" to "theBody"), + error = "theError" + ) + ) + } + + @Test + fun testEncryptionMessageWithUnknownPayloadType() { + val listener = mock(SignalingMessageReceiver.EncryptionMessageListener::class.java) + signalingMessageReceiver.addListener(listener) + + signalingMessageReceiver.processSignalingMessage(signalingMessage("theSessionId", "encryption.unknown")) + signalingMessageReceiver.processSignalingMessage(signalingMessage("theSessionId", null)) + + verifyNoInteractions(listener) + } + + @Test + fun testEncryptionMessageWithoutSender() { + val listener = mock(SignalingMessageReceiver.EncryptionMessageListener::class.java) + signalingMessageReceiver.addListener(listener) + + signalingMessageReceiver.processSignalingMessage(signalingMessage(null, "encryption.start")) + + verifyNoInteractions(listener) + } + + @Test + fun testEncryptionMessageIsNotPassedToOtherListeners() { + val callParticipantListener = mock(SignalingMessageReceiver.CallParticipantMessageListener::class.java) + val webRtcListener = mock(SignalingMessageReceiver.WebRtcMessageListener::class.java) + signalingMessageReceiver.addListener(callParticipantListener, "theSessionId") + signalingMessageReceiver.addListener(webRtcListener, "theSessionId", "video") + + signalingMessageReceiver.processSignalingMessage(signalingMessage("theSessionId", "encryption.start")) + + verifyNoInteractions(callParticipantListener) + verifyNoInteractions(webRtcListener) + } + + @Test + fun testEncryptionMessageAfterRemovingListener() { + val listener = mock(SignalingMessageReceiver.EncryptionMessageListener::class.java) + signalingMessageReceiver.addListener(listener) + signalingMessageReceiver.removeListener(listener) + + signalingMessageReceiver.processSignalingMessage(signalingMessage("theSessionId", "encryption.gotkey")) + + verifyNoInteractions(listener) + } + + @Test + fun testPayloadRoundTrip() { + val message = + EncryptionMessage("encryption.start", id = "theId", identity = "theIdentity", key = "theOneTimeKey") + + assertEquals(message, EncryptionMessage.fromPayload(message.toPayload())) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/CapabilitiesUtilCallEncryptionTest.kt b/app/src/test/java/com/nextcloud/talk/utils/CapabilitiesUtilCallEncryptionTest.kt new file mode 100644 index 00000000000..5ffe4372547 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/CapabilitiesUtilCallEncryptionTest.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CapabilitiesUtilCallEncryptionTest { + + private fun capabilities(feature: Boolean, config: Any?): SpreedCapabilityDto = + SpreedCapabilityDto().apply { + features = if (feature) listOf("call-end-to-end-encryption") else emptyList() + this.config = + hashMapOf("call" to hashMapOf().apply { config?.let { put("end-to-end-encryption", it) } }) + } + + @Test + fun enabledWithFeatureAndConfig() { + assertTrue(CapabilitiesUtil.isCallEndToEndEncryptionEnabled(capabilities(feature = true, config = true))) + } + + @Test + fun disabledWithoutFeature() { + assertFalse(CapabilitiesUtil.isCallEndToEndEncryptionEnabled(capabilities(feature = false, config = true))) + } + + @Test + fun disabledWithoutConfig() { + assertFalse(CapabilitiesUtil.isCallEndToEndEncryptionEnabled(capabilities(feature = true, config = false))) + assertFalse(CapabilitiesUtil.isCallEndToEndEncryptionEnabled(capabilities(feature = true, config = null))) + assertFalse(CapabilitiesUtil.isCallEndToEndEncryptionEnabled(null)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycleTest.kt new file mode 100644 index 00000000000..9af9e163857 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycleTest.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import com.nextcloud.talk.call.e2ee.CallEncryption +import com.nextcloud.talk.call.e2ee.EncryptionMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class CallEncryptionLifecycleTest { + + private var enabled = true + private val created = ArrayList() + private val lifecycle = CallEncryptionLifecycle({ enabled }) { _, _ -> + Mockito.mock(CallEncryption::class.java).also { created.add(it) } + } + private val send: (String, EncryptionMessage) -> Unit = { _, _ -> } + + @Test + fun createdOnRoomJoinWithMcuWhenEnabled() { + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = false, send) + + assertEquals(1, created.size) + assertSame(created[0], lifecycle.current) + } + + @Test + fun notCreatedWithoutPolicyMcuOrSession() { + enabled = false + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = false, send) + enabled = true + lifecycle.roomJoined("session-a", hasMCU = false, alreadyInRoom = false, send) + lifecycle.roomJoined(null, hasMCU = true, alreadyInRoom = false, send) + + assertEquals(0, created.size) + assertNull(lifecycle.current) + } + + @Test + fun keptWhenTheSameRoomIsJoinedAgainAndReplacedOtherwise() { + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = false, send) + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = true, send) + assertEquals(1, created.size) + verify(created[0], never()).close() + + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = false, send) + assertEquals(2, created.size) + verify(created[0]).close() + assertSame(created[1], lifecycle.current) + } + + @Test + fun closedWhenRoomOrSessionIsGone() { + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = false, send) + + lifecycle.close() + + verify(created[0]).close() + assertNull(lifecycle.current) + } + + @Test + fun eventsAndMessagesReachTheExchangeOnlyWhileItExists() { + val message = EncryptionMessage(EncryptionMessage.START, id = "1") + lifecycle.handleMessage("session-b", message) + lifecycle.usersJoined(listOf("session-b")) + + lifecycle.roomJoined("session-a", hasMCU = true, alreadyInRoom = false, send) + lifecycle.handleMessage("session-b", message) + lifecycle.usersJoined(listOf("session-b")) + lifecycle.usersLeft(listOf("session-b")) + + verify(created[0]).handleMessage("session-b", message) + verify(created[0]).usersJoined(listOf("session-b")) + verify(created[0]).usersLeft(listOf("session-b")) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperEncryptionTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperEncryptionTest.kt new file mode 100644 index 00000000000..88d3eba83dd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperEncryptionTest.kt @@ -0,0 +1,235 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import com.nextcloud.talk.call.e2ee.FrameKeyRing +import com.nextcloud.talk.signaling.SignalingMessageReceiver +import com.nextcloud.talk.signaling.SignalingMessageSender +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.kotlin.argThat +import org.webrtc.DataChannel +import org.webrtc.FrameDecryptor +import org.webrtc.FrameEncryptor +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender +import org.webrtc.SessionDescription + +class PeerConnectionWrapperEncryptionTest { + + private class FakeKeyRing : FrameKeyRing { + val encryptors = ArrayList() + val decryptors = ArrayList() + private var disposed = false + + override fun setKey(key: ByteArray, keyIndex: Int): Boolean = true + + override fun attachEncryptor(sender: RtpSender): Boolean { + if (disposed) return false + sender.setFrameEncryptor(FrameEncryptor { 0 }.also { encryptors.add(it) }) + return true + } + + override fun attachDecryptor(receiver: RtpReceiver): Boolean { + if (disposed) return false + receiver.setFrameDecryptor(FrameDecryptor { 0 }.also { decryptors.add(it) }) + return true + } + + override fun dispose() { + disposed = true + } + } + + private lateinit var mockedPeerConnection: PeerConnection + private lateinit var mockedPeerConnectionFactory: PeerConnectionFactory + private lateinit var mockedSignalingMessageReceiver: SignalingMessageReceiver + private lateinit var mockedSignalingMessageSender: SignalingMessageSender + private lateinit var observerCaptor: ArgumentCaptor + + @Before + fun setUp() { + mockedPeerConnection = Mockito.mock(PeerConnection::class.java) + mockedPeerConnectionFactory = Mockito.mock(PeerConnectionFactory::class.java) + mockedSignalingMessageReceiver = Mockito.mock(SignalingMessageReceiver::class.java) + mockedSignalingMessageSender = Mockito.mock(SignalingMessageSender::class.java) + observerCaptor = ArgumentCaptor.forClass(PeerConnection.Observer::class.java) + Mockito.`when`( + mockedPeerConnectionFactory.createPeerConnection( + any(PeerConnection.RTCConfiguration::class.java), + observerCaptor.capture() + ) + ).thenReturn(mockedPeerConnection) + val mockedStatusDataChannel = Mockito.mock(DataChannel::class.java) + Mockito.`when`(mockedStatusDataChannel.label()).thenReturn("status") + Mockito.`when`(mockedPeerConnection.createDataChannel(eq("status"), any())).thenReturn(mockedStatusDataChannel) + } + + private fun createWrapper( + senderKeyRing: FrameKeyRing?, + receiverKeyRing: FrameKeyRing?, + isMCUPublisher: Boolean = senderKeyRing != null, + encryptionRequired: Boolean = false, + localStream: MediaStream? = null + ): PeerConnectionWrapper = + PeerConnectionWrapper( + mockedPeerConnectionFactory, + ArrayList(), + MediaConstraints(), + "the-session-id", + "the-local-session-id", + localStream, + isMCUPublisher, + true, + "video", + mockedSignalingMessageReceiver, + mockedSignalingMessageSender, + encryptionRequired, + senderKeyRing, + receiverKeyRing + ) + + @Test + fun testEncryptorIsAttachedToEverySender() { + val senders = listOf(Mockito.mock(RtpSender::class.java), Mockito.mock(RtpSender::class.java)) + Mockito.`when`(mockedPeerConnection.senders).thenReturn(senders) + val keyRing = FakeKeyRing() + + createWrapper(senderKeyRing = keyRing, receiverKeyRing = null) + + assertEquals(2, keyRing.encryptors.size) + verify(senders[0]).setFrameEncryptor(keyRing.encryptors[0]) + verify(senders[1]).setFrameEncryptor(keyRing.encryptors[1]) + } + + @Test + fun testDecryptorIsAttachedWhenTrackIsAdded() { + val keyRing = FakeKeyRing() + createWrapper(senderKeyRing = null, receiverKeyRing = keyRing) + val receiver = Mockito.mock(RtpReceiver::class.java) + + observerCaptor.value.onAddTrack(receiver, arrayOf()) + + assertEquals(1, keyRing.decryptors.size) + verify(receiver).setFrameDecryptor(keyRing.decryptors[0]) + } + + @Test + fun testEncryptedConnectionWithoutKeysIsRefused() { + for (publisher in listOf(true, false)) { + assertThrows(IllegalStateException::class.java) { + createWrapper( + senderKeyRing = null, + receiverKeyRing = null, + isMCUPublisher = publisher, + encryptionRequired = true + ) + } + } + // A connection that also sends, like one without the MCU, needs both + assertThrows(IllegalStateException::class.java) { + createWrapper( + senderKeyRing = null, + receiverKeyRing = FakeKeyRing(), + isMCUPublisher = false, + encryptionRequired = true, + localStream = Mockito.mock(MediaStream::class.java) + ) + } + + verify(mockedPeerConnectionFactory, never()).createPeerConnection( + any(PeerConnection.RTCConfiguration::class.java), + any(PeerConnection.Observer::class.java) + ) + } + + @Test + fun testEncryptedConnectionsWithKeysAreCreated() { + createWrapper(senderKeyRing = FakeKeyRing(), receiverKeyRing = null, encryptionRequired = true) + createWrapper(senderKeyRing = null, receiverKeyRing = FakeKeyRing(), encryptionRequired = true) + + verify(mockedPeerConnectionFactory, Mockito.times(2)).createPeerConnection( + any(PeerConnection.RTCConfiguration::class.java), + any(PeerConnection.Observer::class.java) + ) + } + + @Test + fun testPublisherWithDisposedKeyRingSendsNothing() { + val sender = Mockito.mock(RtpSender::class.java) + Mockito.`when`(sender.track()).thenReturn(Mockito.mock(org.webrtc.MediaStreamTrack::class.java)) + Mockito.`when`(mockedPeerConnection.senders).thenReturn(listOf(sender)) + val keyRing = FakeKeyRing() + keyRing.dispose() + + createWrapper(senderKeyRing = keyRing, receiverKeyRing = null) + + verify(sender, never()).setFrameEncryptor(any()) + verify(mockedPeerConnection).removeTrack(sender) + } + + @Test + fun testTrackOfDisposedKeyRingIsSkipped() { + val keyRing = FakeKeyRing() + createWrapper(senderKeyRing = null, receiverKeyRing = keyRing) + val receiver = Mockito.mock(RtpReceiver::class.java) + keyRing.dispose() + + observerCaptor.value.onAddTrack(receiver, arrayOf()) + + verify(receiver, never()).setFrameDecryptor(any()) + } + + @Test + fun testNothingIsAttachedWithoutKeyRings() { + val sender = Mockito.mock(RtpSender::class.java) + Mockito.`when`(mockedPeerConnection.senders).thenReturn(listOf(sender)) + createWrapper(senderKeyRing = null, receiverKeyRing = null, isMCUPublisher = true) + val receiver = Mockito.mock(RtpReceiver::class.java) + + observerCaptor.value.onAddTrack(receiver, arrayOf()) + + verify(sender, never()).setFrameEncryptor(any()) + verify(receiver, never()).setFrameDecryptor(any()) + } + + @Test + fun testVideoCodecPreference() { + val sdp = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 98\r\na=rtpmap:96 H264/90000\r\na=rtpmap:98 VP8/90000\r\n" + + createWrapper(senderKeyRing = null, receiverKeyRing = FakeKeyRing()) + createWrapper(senderKeyRing = null, receiverKeyRing = null) + val (encryptedListener, plainListener) = captureWebRtcListeners() + + encryptedListener.onOffer(sdp, null) + verify(mockedPeerConnection).setRemoteDescription(any(), descriptionWith("m=video 9 UDP/TLS/RTP/SAVPF 98 96")) + + plainListener.onOffer(sdp, null) + verify(mockedPeerConnection).setRemoteDescription(any(), descriptionWith("m=video 9 UDP/TLS/RTP/SAVPF 96 98")) + } + + private fun captureWebRtcListeners(): List { + val captor = ArgumentCaptor.forClass(SignalingMessageReceiver.WebRtcMessageListener::class.java) + verify(mockedSignalingMessageReceiver, Mockito.times(2)) + .addListener(captor.capture(), eq("the-session-id"), eq("video")) + return captor.allValues + } + + private fun descriptionWith(mediaLine: String): SessionDescription = argThat { description.contains(mediaLine) } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperTest.kt index d2c6bf7a004..ffc678eb925 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperTest.kt @@ -112,7 +112,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) peerConnectionWrapper!!.send(DataChannelMessageDto("the-message-type")) @@ -151,7 +154,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val mockedRandomIdDataChannel = Mockito.mock(DataChannel::class.java) @@ -198,7 +204,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) peerConnectionWrapper!!.send(DataChannelMessageDto("the-message-type")) @@ -254,7 +263,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val dataChannelMessageCount = 5 @@ -327,7 +339,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val mockedDataChannelMessageListener = Mockito.mock(DataChannelMessageListener::class.java) @@ -408,7 +423,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val randomIdDataChannelObserverArgumentCaptor: ArgumentCaptor = @@ -469,7 +487,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val mockedRandomIdDataChannel = Mockito.mock(DataChannel::class.java) @@ -519,7 +540,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val dataChannelCount = 5 @@ -629,7 +653,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val dataChannelMessageCount = 5 @@ -711,7 +738,10 @@ class PeerConnectionWrapperTest { true, "video", mockedSignalingMessageReceiver, - mockedSignalingMessageSender + mockedSignalingMessageSender, + false, + null, + null ) val mockedDataChannelMessageListener = Mockito.mock(DataChannelMessageListener::class.java) diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceSignalingClientTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceSignalingClientTest.kt index 3235c786455..10dae586971 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceSignalingClientTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceSignalingClientTest.kt @@ -6,9 +6,13 @@ */ package com.nextcloud.talk.webrtc +import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto +import com.nextcloud.talk.models.json.websocket.ActorWebSocketMessageDto +import com.nextcloud.talk.models.json.websocket.CallWebSocketMessageDto import okhttp3.OkHttpClient import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull import org.junit.Test import java.util.concurrent.TimeUnit @@ -34,6 +38,27 @@ class WebSocketInstanceSignalingClientTest { ) } + @Test + fun senderIsTakenFromTheEnvelope() { + val message = CallWebSocketMessageDto( + senderWebSocketMessage = ActorWebSocketMessageDto(type = "session", sessionId = "real-sender"), + ncSignalingMessage = NCSignalingMessageDto(from = "claimed-sender") + ) + + WebSocketInstance.takeSenderFromEnvelope(message) + + assertEquals("real-sender", message.ncSignalingMessage!!.from) + } + + @Test + fun claimedSenderIsDroppedWithoutEnvelope() { + val message = CallWebSocketMessageDto(ncSignalingMessage = NCSignalingMessageDto(from = "claimed-sender")) + + WebSocketInstance.takeSenderFromEnvelope(message) + + assertNull(message.ncSignalingMessage!!.from) + } + @Test fun signalingClientIsDerivedFromBaseClientWithoutMutatingIt() { val baseClient = OkHttpClient.Builder() diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c3dfddd6af4..5f46e6438a6 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -37,6 +37,7 @@ + @@ -19958,6 +19959,14 @@ + + + + + + + +