From c4781785bbf80219b55b2fef722fe057298c9c51 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 30 Sep 2026 22:57:57 +0200 Subject: [PATCH 1/4] feat(calls): add key exchange for end-to-end encrypted calls Port of the web client's e2ee/encryption.js (and the iOS port of it): every participant generates a random frame key and sends it to each other session through a pairwise Olm session over signaling messages, ratchets it when someone joins and rotates it when someone leaves. The Olm account and the frame key rings are behind two small interfaces, so the protocol runs and is tested on the JVM; the vodozemac and WebRTC backed implementations come with their AARs. Signaling routes "message" payloads of the "encryption.*" types to a new listener, the payload model gains the key exchange fields, and the capability check now requires both the signaling feature and the server config. The sender of every relayed signaling message is now taken from the signaling server's envelope instead of the message's own "from", as the web client does: the key exchange must not trust a claimed sender, and the other message types gain the same guarantee. Assisted-by: Claude Code:claude-fable-5-1 Signed-off-by: Andy Scherzinger --- .../talk/call/e2ee/CallEncryption.kt | 435 ++++++++++++++++++ .../talk/call/e2ee/EncryptionMessage.kt | 57 +++ .../nextcloud/talk/call/e2ee/FrameCrypto.kt | 37 ++ .../nextcloud/talk/call/e2ee/OlmAccount.kt | 51 ++ .../nextcloud/talk/models/json/AnyParceler.kt | 2 +- .../json/signaling/EncryptionKeyConverter.kt | 48 ++ .../talk/models/json/signaling/FrameKeyDto.kt | 25 + .../json/signaling/NCMessagePayloadDto.kt | 17 +- .../signaling/EncryptionMessageNotifier.kt | 37 ++ .../signaling/SignalingMessageReceiver.kt | 55 +++ .../nextcloud/talk/utils/CapabilitiesUtil.kt | 7 +- .../talk/webrtc/CallEncryptionLifecycle.kt | 77 ++++ .../talk/webrtc/WebSocketInstance.kt | 16 +- .../talk/call/e2ee/CallEncryptionTest.kt | 394 ++++++++++++++++ .../NCMessagePayloadDtoEncryptionTest.kt | 90 ++++ .../SignalingMessageReceiverEncryptionTest.kt | 122 +++++ .../CapabilitiesUtilCallEncryptionTest.kt | 39 ++ .../webrtc/CallEncryptionLifecycleTest.kt | 86 ++++ .../WebSocketInstanceSignalingClientTest.kt | 25 + 19 files changed, 1607 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/EncryptionMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/FrameCrypto.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/OlmAccount.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/signaling/EncryptionKeyConverter.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/signaling/FrameKeyDto.kt create mode 100644 app/src/main/java/com/nextcloud/talk/signaling/EncryptionMessageNotifier.kt create mode 100644 app/src/main/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycle.kt create mode 100644 app/src/test/java/com/nextcloud/talk/call/e2ee/CallEncryptionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDtoEncryptionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverEncryptionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/CapabilitiesUtilCallEncryptionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/CallEncryptionLifecycleTest.kt 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..c2381857291 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt @@ -0,0 +1,435 @@ +/* + * 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. + */ +@Suppress("TooManyFunctions") +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/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/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/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 9599505c9aa..5bd453598e3 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -187,15 +187,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) } } @@ -546,6 +538,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/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/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() From da88aafba1110a0bf558606109082cb6508e6f0c Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 30 Sep 2026 22:58:19 +0200 Subject: [PATCH 2/4] feat(calls): encrypt calls through the MCU when the server requires it Calls announce the "encryption" feature to the signaling server, the key exchange lives for one joined room and signaling session, and the peer connections attach an encryptor to every sender of the publisher and a decryptor to every receiver of a subscriber, preferring VP8 as the only codec the frame encryption handles. When the server requires encrypted calls but there is no high-performance backend with MCU, or the key exchange could not be set up, the call ends instead of sending media unencrypted. The old "calls not supported" checks and notification are removed. The factory creating the key exchange still reports encryption as unavailable until the WebRTC build with the frame encryption and the vodozemac bindings are published. Assisted-by: Claude Code:claude-fable-5-1 Signed-off-by: Andy Scherzinger --- .../nextcloud/talk/activities/CallActivity.kt | 172 +++++++++---- .../talk/call/e2ee/CallEncryptionFactory.kt | 28 +++ .../CallNotificationActivity.kt | 7 - .../com/nextcloud/talk/chat/ChatActivity.kt | 4 - .../ConversationsListActivity.kt | 4 - .../nextcloud/talk/jobs/NotificationWorker.kt | 35 +-- .../talk/webrtc/PeerConnectionWrapper.java | 55 +++- .../webrtc/WebSocketConnectionHelper.java | 27 +- .../talk/webrtc/WebSocketInstance.kt | 72 +++++- app/src/main/res/values/strings.xml | 4 +- .../PeerConnectionWrapperEncryptionTest.kt | 235 ++++++++++++++++++ .../talk/webrtc/PeerConnectionWrapperTest.kt | 50 +++- 12 files changed, 559 insertions(+), 134 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperEncryptionTest.kt 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/CallEncryptionFactory.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt new file mode 100644 index 00000000000..a7f05340343 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt @@ -0,0 +1,28 @@ +/* + * 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 + + // ponytail: false until the WebRTC build with TalkKeyRing and the vodozemac AAR are available, then the two + // adapters (TalkKeyRing -> FrameCrypto, VodozemacAccount -> OlmCrypto) go here and this becomes true + const val IS_AVAILABLE = false + + fun create( + ownSessionId: String, + sendMessage: (sessionId: String, message: EncryptionMessage) -> Unit + ): CallEncryption? { + Log.w(TAG, "Frame encryption is not available in this build, can not encrypt the call of $ownSessionId") + return null + } +} 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/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 5bd453598e3..888d7c7ea6f 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,9 @@ 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.CallEncryptionFactory +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 +34,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 +87,20 @@ 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 and this build can do it, refreshed whenever this instance is + // handed out again + private var isCallEncryptionEnabled = + CapabilitiesUtil.isCallEndToEndEncryptionEnabled(conversationUser.capabilities?.spreedCapability) && + CallEncryptionFactory.IS_AVAILABLE + + /** + * 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 +109,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 +122,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) ) ) } @@ -275,6 +296,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) { @@ -282,6 +304,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU for (i in leaveEventList!!.indices) { usersHashMap.remove(leaveEventList[i]) } + callEncryptionLifecycle.usersLeft(leaveEventList) } fun getUserMap(): HashMap = usersHashMap @@ -292,7 +315,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 { @@ -302,6 +329,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") @@ -309,10 +364,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) { @@ -337,6 +389,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() @@ -417,6 +473,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 && @@ -464,6 +521,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } fun sendBye() { + callEncryptionLifecycle.close() if (isConnected) { try { val byeWebSocketMessage = ByeWebSocketMessageDto() 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/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) From 3a8f5404926469998bdae0ad8fbf3bdc7403029e Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 1 Oct 2026 07:30:17 +0200 Subject: [PATCH 3/4] chore(calls): allow early returns in the key exchange handlers The guard clauses mirror the web client's code; detekt's ReturnCount would otherwise push the project over its issue threshold. Assisted-by: Claude Code:claude-fable-5-1 Signed-off-by: Andy Scherzinger --- .../main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index c2381857291..9d00b8936f2 100644 --- a/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt @@ -33,7 +33,8 @@ import java.util.UUID * * All state is handled on a single-threaded dispatcher; [keyRing] can be called from any thread. */ -@Suppress("TooManyFunctions") +// 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, From df07fc261a929703f6254cbe985166fbe350a59e Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Thu, 8 Oct 2026 14:32:48 -0500 Subject: [PATCH 4/4] feat(calls): enable end-to-end encrypted calls with WebRTC 155 and vodozemac The key exchange now runs on the real implementations: TalkFrameCrypto wraps the TalkKeyRing of the WebRTC build, VodozemacOlmAccount wraps the vodozemac account and sessions. WebRTC 155.8059.1, already on master, brings the frame encryption; talk-clients-vodozemac 0.0.2 is added, pulling in JNA. Whether a call is encrypted now only depends on the server capability. If the native libraries can not be loaded, the factory returns no key exchange and the call ends instead of sending media unencrypted. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- app/build.gradle.kts | 1 + .../talk/call/e2ee/CallEncryptionFactory.kt | 22 ++++--- .../talk/call/e2ee/TalkFrameCrypto.kt | 52 ++++++++++++++++ .../talk/call/e2ee/VodozemacOlmAccount.kt | 60 +++++++++++++++++++ .../talk/webrtc/WebSocketInstance.kt | 7 +-- gradle/verification-metadata.xml | 9 +++ 6 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/TalkFrameCrypto.kt create mode 100644 app/src/main/java/com/nextcloud/talk/call/e2ee/VodozemacOlmAccount.kt 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/call/e2ee/CallEncryptionFactory.kt b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt index a7f05340343..7f9fa058768 100644 --- a/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt +++ b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt @@ -14,15 +14,21 @@ import android.util.Log object CallEncryptionFactory { private val TAG = CallEncryptionFactory::class.java.simpleName - // ponytail: false until the WebRTC build with TalkKeyRing and the vodozemac AAR are available, then the two - // adapters (TalkKeyRing -> FrameCrypto, VodozemacAccount -> OlmCrypto) go here and this becomes true - const val IS_AVAILABLE = false - + /** + * 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? { - Log.w(TAG, "Frame encryption is not available in this build, can not encrypt the call of $ownSessionId") - return null - } + ): 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/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/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 888d7c7ea6f..b85b0b2c372 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -15,7 +15,6 @@ 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.CallEncryptionFactory import com.nextcloud.talk.call.e2ee.EncryptionMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.NetworkEvent @@ -87,11 +86,9 @@ 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 and this build can do it, refreshed whenever this instance is - // handed out again + // 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) && - CallEncryptionFactory.IS_AVAILABLE + 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 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 @@ + + + + + + + +