From bedbb973fc211bd5e4c2fb8dfe43ecb7eaf9a275 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 21 Oct 2026 23:54:55 +0200 Subject: [PATCH 1/7] fix active connection to srs --- .../com/pedro/rtsp/rtsp/commands/SdpBody.kt | 11 +++++----- .../main/java/com/pedro/whip/WhipClient.kt | 22 ++++++++++++++----- .../main/java/com/pedro/whip/WhipSender.kt | 8 +++---- .../com/pedro/whip/dtls/DtlsConnection.kt | 13 ++++++----- .../java/com/pedro/whip/dtls/DtlsTransport.kt | 2 +- .../com/pedro/whip/webrtc/CommandsManager.kt | 9 ++++++-- 6 files changed, 42 insertions(+), 23 deletions(-) diff --git a/rtsp/src/main/java/com/pedro/rtsp/rtsp/commands/SdpBody.kt b/rtsp/src/main/java/com/pedro/rtsp/rtsp/commands/SdpBody.kt index dbe24520bb..d047aeada6 100644 --- a/rtsp/src/main/java/com/pedro/rtsp/rtsp/commands/SdpBody.kt +++ b/rtsp/src/main/java/com/pedro/rtsp/rtsp/commands/SdpBody.kt @@ -41,7 +41,7 @@ object SdpBody { } else "a=control:streamid=$trackAudio\r\n" return "m=audio $port $type ${payload}\r\n" + "a=rtpmap:$payload OPUS/${RtpConstants.clockOpusFrequency}/2\r\n" + - "a=fmtp:$payload sprop-stereo=${isStereo.toInt()}; sprop-maxcapturerate=$sampleRate\r\n" + + "a=fmtp:$payload sprop-stereo=${isStereo.toInt()};sprop-maxcapturerate=$sampleRate\r\n" + identifier } @@ -72,7 +72,7 @@ object SdpBody { } else "a=control:streamid=$trackAudio\r\n" return "m=audio $port $type ${payload}\r\n" + "a=rtpmap:$payload MPEG4-GENERIC/$sampleRate/$channels\r\n" + - "a=fmtp:$payload profile-level-id=1; mode=AAC-hbr; config=$configHex; sizelength=13; indexlength=3; indexdeltalength=3\r\n" + + "a=fmtp:$payload profile-level-id=1;mode=AAC-hbr;config=$configHex;sizelength=13;indexlength=3;indexdeltalength=3\r\n" + identifier } @@ -88,7 +88,7 @@ object SdpBody { } else "a=control:streamid=$trackVideo\r\n" return "m=video $port $type $payload\r\n" + "a=rtpmap:$payload AV1/${RtpConstants.clockVideoFrequency}\r\n" + - "a=fmtp:$payload profile=${sequenceHeader.seqProfile}; level-idx=${sequenceHeader.seqLevelIdx}\r\n" + + "a=fmtp:$payload profile=${sequenceHeader.seqProfile};level-idx=${sequenceHeader.seqLevelIdx}\r\n" + identifier } @@ -103,13 +103,14 @@ object SdpBody { val profileLevelId = "%02x%02x%02x".format( spsH264Parser.profileIdc, spsH264Parser.profileCompatibility, spsH264Parser.levelIdc ) + val asymmetryAllowed = if (secured) "level-asymmetry-allowed=1;" else "" val identifier = if (secured) { "a=sendonly\r\n" + "a=mid:$trackVideo\r\n" } else "a=control:streamid=$trackVideo\r\n" return "m=video $port $type $payload\r\n" + "a=rtpmap:$payload H264/${RtpConstants.clockVideoFrequency}\r\n" + - "a=fmtp:$payload packetization-mode=1; sprop-parameter-sets=$spsString,$ppsString; profile-level-id=$profileLevelId\r\n" + + "a=fmtp:$payload ${asymmetryAllowed}packetization-mode=1;sprop-parameter-sets=$spsString,$ppsString;profile-level-id=$profileLevelId\r\n" + identifier } @@ -126,7 +127,7 @@ object SdpBody { } else "a=control:streamid=$trackVideo\r\n" return "m=video $port $type ${payload}\r\n" + "a=rtpmap:$payload H265/${RtpConstants.clockVideoFrequency}\r\n" + - "a=fmtp:$payload sprop-sps=$spsString; sprop-pps=$ppsString; sprop-vps=$vpsString\r\n" + + "a=fmtp:$payload sprop-sps=$spsString;sprop-pps=$ppsString;sprop-vps=$vpsString\r\n" + identifier } diff --git a/whip/src/main/java/com/pedro/whip/WhipClient.kt b/whip/src/main/java/com/pedro/whip/WhipClient.kt index 3c2803e64c..686a93ba56 100644 --- a/whip/src/main/java/com/pedro/whip/WhipClient.kt +++ b/whip/src/main/java/com/pedro/whip/WhipClient.kt @@ -269,9 +269,10 @@ class WhipClient(private val connectChecker: ConnectChecker) { val host = remoteCandidates[0].getRealHost() val port = remoteCandidates[0].getRealPort() - val socket = StreamSocket.createUdpSocket(socketType, host, port, socketTimeout, receiveSize = RtpConstants.MTU) + val socket = StreamSocket.createUdpSocket(socketType, host, port, socketTimeout) socket.connect() + val dtlsTransport = DtlsTransport(socket) val requestId = commandsManager.generateTransactionId() val userName = StunAttributeValueParser.createUserName(localFrag, remoteFrag) val attributes = listOf( @@ -283,8 +284,13 @@ class WhipClient(private val connectChecker: ConnectChecker) { var candidateResponses = 0 var requestSuccessReceived = false - while (candidateResponses < 1 || !requestSuccessReceived) { - val command = commandsManager.readStun(socket) + while (!requestSuccessReceived) { + val bytes = socket.read() + if (bytes.isNotEmpty() && (bytes[0].toInt() and 0xFF) in 20..63) { + dtlsTransport.enqueue(bytes) + continue + } + val command = commandsManager.readStun(bytes) if (command.header.id.contentEquals(requestId) && command.header.type == HeaderType.SUCCESS) { requestSuccessReceived = true } else if (command.header.type == HeaderType.REQUEST) { @@ -308,7 +314,12 @@ class WhipClient(private val connectChecker: ConnectChecker) { var nominateSuccessReceived = false while (!nominateSuccessReceived) { - val command = commandsManager.readStun(socket) + val bytes = socket.read() + if (bytes.isNotEmpty() && (bytes[0].toInt() and 0xFF) in 20..63) { + dtlsTransport.enqueue(bytes) + continue + } + val command = commandsManager.readStun(bytes) if (command.header.id.contentEquals(nominateId) && command.header.type == HeaderType.SUCCESS) { nominateSuccessReceived = true } else if (command.header.type == HeaderType.REQUEST) { @@ -325,7 +336,6 @@ class WhipClient(private val connectChecker: ConnectChecker) { val fingerprint = commandsManager.remoteSdpInfo?.fingerprint ?: return@launch val dtlsResult = CompletableDeferred>>() - val dtlsTransport = DtlsTransport(socket) dtlsConnection = DtlsConnection(certificate, fingerprint) val dispatchJob = launch { @@ -362,7 +372,7 @@ class WhipClient(private val connectChecker: ConnectChecker) { Log.i(TAG, "dtls connected!!") onMainThread { connectChecker.onConnectionSuccess() } whipSender.setSocketsInfo(socket) - whipSender.setCrypto(cryptoProperties[1]) + whipSender.setCrypto(cryptoProperties[1], cryptoProperties[3]) whipSender.start() }.exceptionOrNull() if (error != null) { diff --git a/whip/src/main/java/com/pedro/whip/WhipSender.kt b/whip/src/main/java/com/pedro/whip/WhipSender.kt index 8eef0ca2f2..682d7fa67e 100644 --- a/whip/src/main/java/com/pedro/whip/WhipSender.kt +++ b/whip/src/main/java/com/pedro/whip/WhipSender.kt @@ -45,10 +45,10 @@ class WhipSender( baseSenderReport = BaseSenderReport.getInstance(commandsManager.rtpTracks, socket) } - fun setCrypto(properties: CryptoProperties) { - baseSenderReport?.setCrypto(properties) - videoPacket.setCryptoProperties(properties) - audioPacket.setCryptoProperties(properties) + fun setCrypto(rtpProperties: CryptoProperties, rtcpProperties: CryptoProperties) { + baseSenderReport?.setCrypto(rtcpProperties) + videoPacket.setCryptoProperties(rtpProperties) + audioPacket.setCryptoProperties(rtpProperties) } override fun setVideoInfo(sps: ByteBuffer, pps: ByteBuffer?, vps: ByteBuffer?) { diff --git a/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt b/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt index 017f4aadff..0319276bd1 100644 --- a/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt +++ b/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt @@ -204,17 +204,20 @@ class DtlsConnection( System.arraycopy(keys, offs, serverMasterSalt, 0, saltLength) return listOf( deriveCryptoProperties(clientMasterKey, clientMasterSalt), - deriveCryptoProperties(serverMasterKey, serverMasterSalt) + deriveCryptoProperties(serverMasterKey, serverMasterSalt), + deriveCryptoProperties(clientMasterKey, clientMasterSalt, true), + deriveCryptoProperties(serverMasterKey, serverMasterSalt, true) ) } // RFC 3711 4.3.1: derive SRTP session keys from master key + master salt via AES-CM PRF. // label 0x00 → cipher key, 0x01 → auth key, 0x02 → session salt // x = master_salt with byte[7] XOR'd with the label (label * 2^48 in 112-bit representation) - private fun deriveCryptoProperties(masterKey: ByteArray, masterSalt: ByteArray): CryptoProperties { - val cipherKey = deriveKey(masterKey, masterSalt, 0x00.toByte(), 16) - val authKey = deriveKey(masterKey, masterSalt, 0x01.toByte(), 20) - val salt = deriveKey(masterKey, masterSalt, 0x02.toByte(), 14) + private fun deriveCryptoProperties(masterKey: ByteArray, masterSalt: ByteArray, rtcp: Boolean = false): CryptoProperties { + val base = if (rtcp) 0x03 else 0x00 + val cipherKey = deriveKey(masterKey, masterSalt, (base + 0).toByte(), 16) + val authKey = deriveKey(masterKey, masterSalt, (base + 1).toByte(), 20) + val salt = deriveKey(masterKey, masterSalt, (base + 2).toByte(), 14) return CryptoProperties(authKey, cipherKey, salt) } diff --git a/whip/src/main/java/com/pedro/whip/dtls/DtlsTransport.kt b/whip/src/main/java/com/pedro/whip/dtls/DtlsTransport.kt index d9216499ef..3358f33b7b 100644 --- a/whip/src/main/java/com/pedro/whip/dtls/DtlsTransport.kt +++ b/whip/src/main/java/com/pedro/whip/dtls/DtlsTransport.kt @@ -61,7 +61,7 @@ class DtlsTransport( override fun send(buffer: ByteArray, offset: Int, length: Int) { runBlocking { - socket.write(buffer.sliceArray(offset until length)) + socket.write(buffer.sliceArray(offset until offset + length)) } } diff --git a/whip/src/main/java/com/pedro/whip/webrtc/CommandsManager.kt b/whip/src/main/java/com/pedro/whip/webrtc/CommandsManager.kt index c004046aca..4ebd479905 100644 --- a/whip/src/main/java/com/pedro/whip/webrtc/CommandsManager.kt +++ b/whip/src/main/java/com/pedro/whip/webrtc/CommandsManager.kt @@ -331,7 +331,10 @@ class CommandsManager { if (media.isNotEmpty()) { videoBody = media + "a=rtcp-mux\r\n" + - "a=ssrc:$videoSsrc cname:$cName\r\n" + "a=setup:actpass\r\n" + + "a=ssrc:$videoSsrc cname:$cName\r\n" + + "a=ssrc:$videoSsrc msid:$cName video\r\n" + } } var audioBody = "" @@ -343,7 +346,9 @@ class CommandsManager { } audioBody = media + "a=rtcp-mux\r\n" + - "a=ssrc:$audioSsrc cname:$cName\r\n" + "a=setup:actpass\r\n" + + "a=ssrc:$audioSsrc cname:$cName\r\n" + + "a=ssrc:$audioSsrc msid:$cName audio\r\n" } val bundleMids = listOfNotNull( if (videoBody.isNotEmpty()) rtpTracks.trackVideo else null, From 648c686ca02dc98e5fac91c9e6adf16cecec6820 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 22 Oct 2026 00:07:19 +0200 Subject: [PATCH 2/7] fix sdpbody tests --- rtsp/src/test/java/com/pedro/rtsp/rtsp/SdpBodyTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rtsp/src/test/java/com/pedro/rtsp/rtsp/SdpBodyTest.kt b/rtsp/src/test/java/com/pedro/rtsp/rtsp/SdpBodyTest.kt index f7ee360959..22fe133521 100644 --- a/rtsp/src/test/java/com/pedro/rtsp/rtsp/SdpBodyTest.kt +++ b/rtsp/src/test/java/com/pedro/rtsp/rtsp/SdpBodyTest.kt @@ -84,11 +84,11 @@ class SdpBodyTest { val stereo48k = SdpBody.createOpusBody(track, 48000, true) assertTrue(stereo48k.contains(expectedType)) - assertTrue(stereo48k.contains("a=fmtp:$payload sprop-stereo=1; sprop-maxcapturerate=48000\r\n")) + assertTrue(stereo48k.contains("a=fmtp:$payload sprop-stereo=1;sprop-maxcapturerate=48000\r\n")) val mono16k = SdpBody.createOpusBody(track, 16000, false) assertTrue(mono16k.contains(expectedType)) - assertTrue(mono16k.contains("a=fmtp:$payload sprop-stereo=0; sprop-maxcapturerate=16000\r\n")) + assertTrue(mono16k.contains("a=fmtp:$payload sprop-stereo=0;sprop-maxcapturerate=16000\r\n")) } @Test @@ -183,7 +183,7 @@ class SdpBodyTest { val track = 1 val expectedType = "H265/${RtpConstants.clockVideoFrequency}" - val expectedConfig = "sprop-sps=QgEBAQ==; sprop-pps=RAHAZg==; sprop-vps=QAEMAQ==" + val expectedConfig = "sprop-sps=QgEBAQ==;sprop-pps=RAHAZg==;sprop-vps=QAEMAQ==" val expectedPayload = "a=rtpmap:${RtpConstants.payloadType + track}" val expectedTrack = "a=control:streamid=${track}" @@ -209,7 +209,7 @@ class SdpBodyTest { assertTrue(result.contains(expectedPayload)) assertTrue(result.contains(expectedTrack)) //seq_profile and seq_level_idx of the obu payload. Parsing the raw obu instead would report 8 - assertTrue(result.contains("a=fmtp:${RtpConstants.payloadType + track} profile=0; level-idx=4\r\n")) + assertTrue(result.contains("a=fmtp:${RtpConstants.payloadType + track} profile=0;level-idx=4\r\n")) } @Test From 7a488651cde59fda4a712b1de0daf16f1cc8e502 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 22 Oct 2026 00:42:20 +0200 Subject: [PATCH 3/7] support whep dtls client mode --- .../main/java/com/pedro/whip/WhipClient.kt | 24 +- .../pedro/whip/dtls/DtlsClientConnection.kt | 112 +++++++++ .../com/pedro/whip/dtls/DtlsConnection.kt | 225 +----------------- .../pedro/whip/dtls/DtlsServerConnection.kt | 135 +++++++++++ .../java/com/pedro/whip/dtls/DtlsUtils.kt | 147 ++++++++++++ .../main/java/com/pedro/whip/dtls/SrtpKeys.kt | 30 +++ .../java/com/pedro/whip/webrtc/SdpInfo.kt | 3 +- .../java/com/pedro/whip/webrtc/SdpParser.kt | 8 +- .../java/com/pedro/whip/dtls/DtlsUtilsTest.kt | 65 +++++ .../com/pedro/whip/webrtc/SdpParserTest.kt | 74 ++++++ 10 files changed, 598 insertions(+), 225 deletions(-) create mode 100644 whip/src/main/java/com/pedro/whip/dtls/DtlsClientConnection.kt create mode 100644 whip/src/main/java/com/pedro/whip/dtls/DtlsServerConnection.kt create mode 100644 whip/src/main/java/com/pedro/whip/dtls/DtlsUtils.kt create mode 100644 whip/src/main/java/com/pedro/whip/dtls/SrtpKeys.kt create mode 100644 whip/src/test/java/com/pedro/whip/dtls/DtlsUtilsTest.kt create mode 100644 whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt diff --git a/whip/src/main/java/com/pedro/whip/WhipClient.kt b/whip/src/main/java/com/pedro/whip/WhipClient.kt index 686a93ba56..e158aa91b0 100644 --- a/whip/src/main/java/com/pedro/whip/WhipClient.kt +++ b/whip/src/main/java/com/pedro/whip/WhipClient.kt @@ -14,10 +14,12 @@ import com.pedro.common.socket.base.UdpStreamSocket import com.pedro.common.toMediaFrameInfo import com.pedro.common.toUInt32 import com.pedro.common.validMessage -import com.pedro.rtsp.utils.CryptoProperties import com.pedro.rtsp.utils.RtpConstants +import com.pedro.whip.dtls.DtlsClientConnection import com.pedro.whip.dtls.DtlsConnection +import com.pedro.whip.dtls.DtlsServerConnection import com.pedro.whip.dtls.DtlsTransport +import com.pedro.whip.dtls.SrtpKeys import com.pedro.whip.utils.Network import com.pedro.whip.webrtc.CandidateType import com.pedro.whip.webrtc.CommandsManager @@ -335,8 +337,14 @@ class WhipClient(private val connectChecker: ConnectChecker) { val certificate = commandsManager.certificate ?: return@launch val fingerprint = commandsManager.remoteSdpInfo?.fingerprint ?: return@launch - val dtlsResult = CompletableDeferred>>() - dtlsConnection = DtlsConnection(certificate, fingerprint) + val dtlsResult = CompletableDeferred>() + val isDtlsServer = commandsManager.remoteSdpInfo?.isActiveMode ?: false + Log.i(TAG, "Using dtls ${if (isDtlsServer) "server" else "client"} role") + dtlsConnection = if (isDtlsServer) { + DtlsServerConnection(certificate, fingerprint) + } else { + DtlsClientConnection(certificate, fingerprint) + } val dispatchJob = launch { while (isActive) { @@ -349,8 +357,8 @@ class WhipClient(private val connectChecker: ConnectChecker) { } dtlsConnection?.start(dtlsTransport, object : DtlsConnection.Callback { - override fun onHandshakeComplete(properties: List) { - dtlsResult.complete(Result.success(properties)) + override fun onHandshakeComplete(keys: SrtpKeys) { + dtlsResult.complete(Result.success(keys)) } override fun onHandshakeFailed(reason: String?) { dtlsResult.complete(Result.failure(Exception(reason ?: "DTLS handshake failed"))) @@ -359,8 +367,8 @@ class WhipClient(private val connectChecker: ConnectChecker) { val result = withTimeoutOrNull(5_000.milliseconds) { dtlsResult.await() } ?: Result.failure(Exception("timeout")) - val cryptoProperties = result.getOrNull() - if (cryptoProperties == null) { + val srtpKeys = result.getOrNull() + if (srtpKeys == null) { dispatchJob.cancel() dtlsConnection?.close() dtlsConnection = null @@ -372,7 +380,7 @@ class WhipClient(private val connectChecker: ConnectChecker) { Log.i(TAG, "dtls connected!!") onMainThread { connectChecker.onConnectionSuccess() } whipSender.setSocketsInfo(socket) - whipSender.setCrypto(cryptoProperties[1], cryptoProperties[3]) + whipSender.setCrypto(srtpKeys.rtp, srtpKeys.rtcp) whipSender.start() }.exceptionOrNull() if (error != null) { diff --git a/whip/src/main/java/com/pedro/whip/dtls/DtlsClientConnection.kt b/whip/src/main/java/com/pedro/whip/dtls/DtlsClientConnection.kt new file mode 100644 index 0000000000..536d00c9da --- /dev/null +++ b/whip/src/main/java/com/pedro/whip/dtls/DtlsClientConnection.kt @@ -0,0 +1,112 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.whip.dtls + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.bouncycastle.tls.AlertDescription +import org.bouncycastle.tls.CertificateRequest +import org.bouncycastle.tls.DTLSClientProtocol +import org.bouncycastle.tls.DTLSTransport +import org.bouncycastle.tls.DefaultTlsClient +import org.bouncycastle.tls.ProtocolVersion +import org.bouncycastle.tls.SRTPProtectionProfile +import org.bouncycastle.tls.SignatureAlgorithm +import org.bouncycastle.tls.TlsAuthentication +import org.bouncycastle.tls.TlsCredentials +import org.bouncycastle.tls.TlsExtensionsUtils +import org.bouncycastle.tls.TlsFatalAlert +import org.bouncycastle.tls.TlsSRTPUtils +import org.bouncycastle.tls.TlsServerCertificate +import org.bouncycastle.tls.TlsUtils +import org.bouncycastle.tls.UseSRTPData +import java.util.Hashtable + +/** + * DTLS client role, used when the remote answer is a=setup:passive so we must start the handshake. + */ +class DtlsClientConnection( + private val certificate: DtlsCertificate, + private val remoteFingerprint: String +) : DefaultTlsClient(certificate.crypto), DtlsConnection { + + private val srtpProfile = SRTPProtectionProfile.SRTP_AES128_CM_HMAC_SHA1_80 + private var dtlsTransport: DTLSTransport? = null + private var exportedSrtpKeys: ByteArray? = null + + override fun start(transport: DtlsTransport, callback: DtlsConnection.Callback) { + CoroutineScope(Dispatchers.IO).launch { + try { + dtlsTransport = DTLSClientProtocol().connect(this@DtlsClientConnection, transport) + val keys = exportedSrtpKeys + ?: throw IllegalStateException("SRTP keying material not captured during handshake") + callback.onHandshakeComplete(DtlsUtils.deriveSrtpKeys(keys, isClient = true)) + } catch (e: Exception) { + callback.onHandshakeFailed(e.message) + } + } + } + + override fun close() { + runCatching { dtlsTransport?.close() } + } + + override fun getSupportedVersions(): Array = + arrayOf(ProtocolVersion.DTLSv12) + + override fun getClientExtensions(): Hashtable<*, *> { + val extensions = TlsExtensionsUtils.ensureExtensionsInitialised(super.getClientExtensions()) + TlsSRTPUtils.addUseSRTPExtension(extensions, UseSRTPData(intArrayOf(srtpProfile), TlsUtils.EMPTY_BYTES)) + return extensions + } + + override fun processServerExtensions(serverExtensions: Hashtable<*, *>?) { + super.processServerExtensions(serverExtensions) + //the server must accept use_srtp with our profile, if not we can't derive the SRTP keys + val useSrtp = TlsSRTPUtils.getUseSRTPExtension(serverExtensions) + ?: throw TlsFatalAlert(AlertDescription.handshake_failure, "server doesn't support use_srtp") + if (useSrtp.protectionProfiles.size != 1 || useSrtp.protectionProfiles[0] != srtpProfile) { + throw TlsFatalAlert(AlertDescription.illegal_parameter, "invalid SRTP protection profile") + } + } + + override fun getAuthentication(): TlsAuthentication { + val context = context + return object : TlsAuthentication { + override fun notifyServerCertificate(serverCertificate: TlsServerCertificate) { + DtlsUtils.verifyFingerprint(serverCertificate.certificate, remoteFingerprint) + } + + //WebRTC requires mutual authentication, the server validates our fingerprint + override fun getClientCredentials(certificateRequest: CertificateRequest): TlsCredentials { + val sigAlgs = certificateRequest.supportedSignatureAlgorithms + ?: TlsUtils.getDefaultSupportedSignatureAlgorithms(context) + return DtlsUtils.buildSignerCredentials(context, certificate, sigAlgs, SignatureAlgorithm.rsa) + } + } + } + + override fun notifyHandshakeComplete() { + super.notifyHandshakeComplete() + try { + exportedSrtpKeys = DtlsUtils.exportSrtpKeys(context) + } catch (_: Exception) { } + } +} diff --git a/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt b/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt index 0319276bd1..2167c3790f 100644 --- a/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt +++ b/whip/src/main/java/com/pedro/whip/dtls/DtlsConnection.kt @@ -18,227 +18,22 @@ package com.pedro.whip.dtls -import android.util.Log -import com.pedro.rtsp.utils.CryptoProperties -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.bouncycastle.tls.Certificate -import org.bouncycastle.tls.CertificateRequest -import org.bouncycastle.tls.ClientCertificateType -import org.bouncycastle.tls.DTLSServerProtocol -import org.bouncycastle.tls.DTLSTransport -import org.bouncycastle.tls.DefaultTlsServer -import org.bouncycastle.tls.ExtensionType -import org.bouncycastle.tls.HashAlgorithm -import org.bouncycastle.tls.ProtocolName -import org.bouncycastle.tls.ProtocolVersion -import org.bouncycastle.tls.SignatureAndHashAlgorithm -import org.bouncycastle.tls.TlsCredentialedDecryptor -import org.bouncycastle.tls.TlsCredentialedSigner -import org.bouncycastle.tls.TlsSRTPUtils -import org.bouncycastle.tls.TlsUtils -import org.bouncycastle.tls.crypto.TlsCertificate -import org.bouncycastle.tls.crypto.TlsCryptoParameters -import org.bouncycastle.tls.crypto.impl.bc.BcDefaultTlsCredentialedDecryptor -import org.bouncycastle.tls.crypto.impl.bc.BcDefaultTlsCredentialedSigner -import java.io.ByteArrayInputStream -import java.io.IOException -import java.nio.ByteBuffer -import java.security.MessageDigest -import java.security.cert.CertificateException -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.util.Hashtable -import java.util.Vector -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec - /** - * Created by pedro on 21/7/25. + * DTLS handshake for DTLS-SRTP. The role depends on the a=setup value of the remote answer: + * active -> the remote starts the handshake and we are the server ([DtlsServerConnection]) + * passive -> we start the handshake and we are the client ([DtlsClientConnection]) */ -class DtlsConnection( - private val certificate: DtlsCertificate, - private val remoteFingerprint: String -) : DefaultTlsServer(certificate.crypto) { - +interface DtlsConnection { interface Callback { - fun onHandshakeComplete(properties: List) + /** + * @param keys used to protect the packets that we send (depends on our DTLS role) + */ + fun onHandshakeComplete(keys: SrtpKeys) fun onHandshakeFailed(reason: String?) } - private val wantRTP = true - private var dtlsTransport: DTLSTransport? = null - private var exportedSrtpKeys: ByteArray? = null - - fun start(transport: DtlsTransport, callback: Callback) { - CoroutineScope(Dispatchers.IO).launch { - try { - dtlsTransport = DTLSServerProtocol().accept(this@DtlsConnection, transport) - val keys = exportedSrtpKeys - ?: throw IllegalStateException("SRTP keying material not captured during handshake") - val props = deriveCryptoPropertiesFromExport(keys) - callback.onHandshakeComplete(props) - } catch (e: Exception) { - callback.onHandshakeFailed(e.message) - } - } - } - - fun close() { - runCatching { dtlsTransport?.close() } - } - - override fun getSupportedVersions(): Array = - arrayOf(ProtocolVersion.DTLSv12) - - override fun getCertificateRequest(): CertificateRequest { - val types = shortArrayOf( - ClientCertificateType.rsa_sign, - ClientCertificateType.dss_sign, - ClientCertificateType.ecdsa_sign - ) - val sigAlgs = if (TlsUtils.isSignatureAlgorithmsExtensionAllowed(serverVersion)) { - TlsUtils.getDefaultSupportedSignatureAlgorithms(context) - } else null - return CertificateRequest(types, sigAlgs, null) - } + fun start(transport: DtlsTransport, callback: Callback) - override fun getProtocolNames(): Vector = - Vector(1).apply { add(ProtocolName.WEBRTC) } - - override fun getRSAEncryptionCredentials(): TlsCredentialedDecryptor { - val certif = Certificate(arrayOf(certificate.certificate)) - return BcDefaultTlsCredentialedDecryptor(certificate.crypto, certif, certificate.key) - } - - override fun getRSASignerCredentials(): TlsCredentialedSigner = - buildSignerCredentials(ClientCertificateType.rsa_sign) - - override fun getECDSASignerCredentials(): TlsCredentialedSigner = - buildSignerCredentials(ClientCertificateType.ecdsa_sign) - - private fun buildSignerCredentials(signatureType: Short): TlsCredentialedSigner { - val certif = Certificate(arrayOf(certificate.certificate)) - val cryptoParams = TlsCryptoParameters(context) - val sigAlgs = TlsUtils.getDefaultSupportedSignatureAlgorithms(context) as Vector - val sigAlg = sigAlgs.filterIsInstance() - .firstOrNull { it.signature == signatureType && it.hash == HashAlgorithm.sha256 } - ?: sigAlgs.first() as SignatureAndHashAlgorithm - return BcDefaultTlsCredentialedSigner(cryptoParams, certificate.crypto, certificate.key, certif, sigAlg) - } - - override fun getServerExtensions(): Hashtable<*, *>? { - var ret = super.getServerExtensions() - if (wantRTP && clientExtensions?.containsKey(ExtensionType.use_srtp) == true) { - val prof = ByteArray(5) - ByteBuffer.wrap(prof).apply { - putChar(2.toChar()) - put(byteArrayOf(0x00, 0x01)) - put(0.toByte()) - } - if (ret == null) ret = Hashtable() - (ret as Hashtable)[ExtensionType.use_srtp] = prof - } - return ret - } - - override fun notifyClientCertificate(clientCertificate: Certificate) { - val certs = clientCertificate.certificateList - if (certs.isNullOrEmpty()) throw IOException("no certs offered") - try { - validateX509(certs[0]) - val fingerprint = computeFingerprint(certs[0]) - if (!fingerprint.equals(remoteFingerprint, ignoreCase = true)) { - throw IOException("fingerprints don't match") - } - } catch (e: CertificateException) { - throw IOException("offered cert is invalid: ${e.message}") - } - } - - override fun processClientExtensions(clientExtensions: Hashtable<*, *>?) { - TlsSRTPUtils.getUseSRTPExtension(clientExtensions) - super.processClientExtensions(clientExtensions) - } - - // exportKeyingMaterial() is only valid inside this callback — capture it here. - override fun notifyHandshakeComplete() { - super.notifyHandshakeComplete() - try { - val keyLength = 16 - val saltLength = 14 - exportedSrtpKeys = context.exportKeyingMaterial( - "EXTRACTOR-dtls_srtp", null, 2 * (keyLength + saltLength) - ) - } catch (_: Exception) { } - } - - private fun validateX509(cert: TlsCertificate) { - val cf = CertificateFactory.getInstance("X.509") - val bis = ByteArrayInputStream(cert.encoded) - while (bis.available() > 0) { - (cf.generateCertificate(bis) as X509Certificate).checkValidity() - } - } - - private fun computeFingerprint(cert: TlsCertificate): String { - val digest = MessageDigest.getInstance("SHA-256").digest(cert.encoded) - return digest.joinToString(":") { "%02X".format(it) } - } - - private fun deriveCryptoPropertiesFromExport(keys: ByteArray): List { - // RFC 5764: exported keying material layout for AES_CM_128_HMAC_SHA1_80 - // [clientMasterKey(16) | serverMasterKey(16) | clientMasterSalt(14) | serverMasterSalt(14)] - val keyLength = 16 - val saltLength = 14 - val clientMasterKey = ByteArray(keyLength) - val serverMasterKey = ByteArray(keyLength) - val clientMasterSalt = ByteArray(saltLength) - val serverMasterSalt = ByteArray(saltLength) - var offs = 0 - System.arraycopy(keys, offs, clientMasterKey, 0, keyLength); offs += keyLength - System.arraycopy(keys, offs, serverMasterKey, 0, keyLength); offs += keyLength - System.arraycopy(keys, offs, clientMasterSalt, 0, saltLength); offs += saltLength - System.arraycopy(keys, offs, serverMasterSalt, 0, saltLength) - return listOf( - deriveCryptoProperties(clientMasterKey, clientMasterSalt), - deriveCryptoProperties(serverMasterKey, serverMasterSalt), - deriveCryptoProperties(clientMasterKey, clientMasterSalt, true), - deriveCryptoProperties(serverMasterKey, serverMasterSalt, true) - ) - } - - // RFC 3711 4.3.1: derive SRTP session keys from master key + master salt via AES-CM PRF. - // label 0x00 → cipher key, 0x01 → auth key, 0x02 → session salt - // x = master_salt with byte[7] XOR'd with the label (label * 2^48 in 112-bit representation) - private fun deriveCryptoProperties(masterKey: ByteArray, masterSalt: ByteArray, rtcp: Boolean = false): CryptoProperties { - val base = if (rtcp) 0x03 else 0x00 - val cipherKey = deriveKey(masterKey, masterSalt, (base + 0).toByte(), 16) - val authKey = deriveKey(masterKey, masterSalt, (base + 1).toByte(), 20) - val salt = deriveKey(masterKey, masterSalt, (base + 2).toByte(), 14) - return CryptoProperties(authKey, cipherKey, salt) - } - - private fun deriveKey(masterKey: ByteArray, masterSalt: ByteArray, label: Byte, lengthBytes: Int): ByteArray { - val x = masterSalt.copyOf(14) - x[7] = (x[7].toInt() xor label.toInt()).toByte() - val iv = ByteArray(16).also { System.arraycopy(x, 0, it, 0, 14) } - val cipher = Cipher.getInstance("AES/ECB/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(masterKey, "AES")) - val result = ByteArray(lengthBytes) - var offset = 0 - var counter = 0 - while (offset < lengthBytes) { - iv[14] = (counter shr 8).toByte() - iv[15] = counter.toByte() - val block = cipher.doFinal(iv) - val toCopy = minOf(16, lengthBytes - offset) - System.arraycopy(block, 0, result, offset, toCopy) - offset += toCopy - counter++ - } - return result - } + fun close() } diff --git a/whip/src/main/java/com/pedro/whip/dtls/DtlsServerConnection.kt b/whip/src/main/java/com/pedro/whip/dtls/DtlsServerConnection.kt new file mode 100644 index 0000000000..78dd7bd7d1 --- /dev/null +++ b/whip/src/main/java/com/pedro/whip/dtls/DtlsServerConnection.kt @@ -0,0 +1,135 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.whip.dtls + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.bouncycastle.tls.Certificate +import org.bouncycastle.tls.CertificateRequest +import org.bouncycastle.tls.ClientCertificateType +import org.bouncycastle.tls.DTLSServerProtocol +import org.bouncycastle.tls.DTLSTransport +import org.bouncycastle.tls.DefaultTlsServer +import org.bouncycastle.tls.ExtensionType +import org.bouncycastle.tls.ProtocolName +import org.bouncycastle.tls.ProtocolVersion +import org.bouncycastle.tls.TlsCredentialedDecryptor +import org.bouncycastle.tls.TlsCredentialedSigner +import org.bouncycastle.tls.TlsSRTPUtils +import org.bouncycastle.tls.TlsUtils +import org.bouncycastle.tls.crypto.impl.bc.BcDefaultTlsCredentialedDecryptor +import java.nio.ByteBuffer +import java.util.Hashtable +import java.util.Vector + +/** + * Created by pedro on 21/7/25. + */ +class DtlsServerConnection( + private val certificate: DtlsCertificate, + private val remoteFingerprint: String +) : DefaultTlsServer(certificate.crypto), DtlsConnection { + + private val wantRTP = true + private var dtlsTransport: DTLSTransport? = null + private var exportedSrtpKeys: ByteArray? = null + + override fun start(transport: DtlsTransport, callback: DtlsConnection.Callback) { + CoroutineScope(Dispatchers.IO).launch { + try { + dtlsTransport = DTLSServerProtocol().accept(this@DtlsServerConnection, transport) + val keys = exportedSrtpKeys + ?: throw IllegalStateException("SRTP keying material not captured during handshake") + callback.onHandshakeComplete(DtlsUtils.deriveSrtpKeys(keys, isClient = false)) + } catch (e: Exception) { + callback.onHandshakeFailed(e.message) + } + } + } + + override fun close() { + runCatching { dtlsTransport?.close() } + } + + override fun getSupportedVersions(): Array = + arrayOf(ProtocolVersion.DTLSv12) + + override fun getCertificateRequest(): CertificateRequest { + val types = shortArrayOf( + ClientCertificateType.rsa_sign, + ClientCertificateType.dss_sign, + ClientCertificateType.ecdsa_sign + ) + val sigAlgs = if (TlsUtils.isSignatureAlgorithmsExtensionAllowed(serverVersion)) { + TlsUtils.getDefaultSupportedSignatureAlgorithms(context) + } else null + return CertificateRequest(types, sigAlgs, null) + } + + override fun getProtocolNames(): Vector = + Vector(1).apply { add(ProtocolName.WEBRTC) } + + override fun getRSAEncryptionCredentials(): TlsCredentialedDecryptor { + val certif = Certificate(arrayOf(certificate.certificate)) + return BcDefaultTlsCredentialedDecryptor(certificate.crypto, certif, certificate.key) + } + + override fun getRSASignerCredentials(): TlsCredentialedSigner = + buildSignerCredentials(ClientCertificateType.rsa_sign) + + override fun getECDSASignerCredentials(): TlsCredentialedSigner = + buildSignerCredentials(ClientCertificateType.ecdsa_sign) + + private fun buildSignerCredentials(signatureType: Short): TlsCredentialedSigner { + val sigAlgs = TlsUtils.getDefaultSupportedSignatureAlgorithms(context) + return DtlsUtils.buildSignerCredentials(context, certificate, sigAlgs, signatureType) + } + + override fun getServerExtensions(): Hashtable<*, *>? { + var ret = super.getServerExtensions() + if (wantRTP && clientExtensions?.containsKey(ExtensionType.use_srtp) == true) { + val prof = ByteArray(5) + ByteBuffer.wrap(prof).apply { + putChar(2.toChar()) + put(byteArrayOf(0x00, 0x01)) + put(0.toByte()) + } + if (ret == null) ret = Hashtable() + (ret as Hashtable)[ExtensionType.use_srtp] = prof + } + return ret + } + + override fun notifyClientCertificate(clientCertificate: Certificate) { + DtlsUtils.verifyFingerprint(clientCertificate, remoteFingerprint) + } + + override fun processClientExtensions(clientExtensions: Hashtable<*, *>?) { + TlsSRTPUtils.getUseSRTPExtension(clientExtensions) + super.processClientExtensions(clientExtensions) + } + + override fun notifyHandshakeComplete() { + super.notifyHandshakeComplete() + try { + exportedSrtpKeys = DtlsUtils.exportSrtpKeys(context) + } catch (_: Exception) { } + } +} diff --git a/whip/src/main/java/com/pedro/whip/dtls/DtlsUtils.kt b/whip/src/main/java/com/pedro/whip/dtls/DtlsUtils.kt new file mode 100644 index 0000000000..01cfd598a2 --- /dev/null +++ b/whip/src/main/java/com/pedro/whip/dtls/DtlsUtils.kt @@ -0,0 +1,147 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.whip.dtls + +import com.pedro.rtsp.utils.CryptoProperties +import org.bouncycastle.tls.Certificate +import org.bouncycastle.tls.ExporterLabel +import org.bouncycastle.tls.HashAlgorithm +import org.bouncycastle.tls.SignatureAndHashAlgorithm +import org.bouncycastle.tls.TlsContext +import org.bouncycastle.tls.TlsCredentialedSigner +import org.bouncycastle.tls.crypto.TlsCertificate +import org.bouncycastle.tls.crypto.TlsCryptoParameters +import org.bouncycastle.tls.crypto.impl.bc.BcDefaultTlsCredentialedSigner +import java.io.ByteArrayInputStream +import java.io.IOException +import java.security.MessageDigest +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Vector +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +/** + * Code shared by the DTLS client and server roles. + */ +object DtlsUtils { + + private const val KEY_LENGTH = 16 + private const val SALT_LENGTH = 14 + + fun verifyFingerprint(certificate: Certificate, remoteFingerprint: String) { + val certs = certificate.certificateList + if (certs.isNullOrEmpty()) throw IOException("no certs offered") + try { + validateX509(certs[0]) + val fingerprint = computeFingerprint(certs[0]) + if (!fingerprint.equals(remoteFingerprint, ignoreCase = true)) { + throw IOException("fingerprints don't match") + } + } catch (e: CertificateException) { + throw IOException("offered cert is invalid: ${e.message}") + } + } + + fun buildSignerCredentials( + context: TlsContext, certificate: DtlsCertificate, + signatureAlgorithms: Vector<*>, signatureType: Short + ): TlsCredentialedSigner { + val certif = Certificate(arrayOf(certificate.certificate)) + val cryptoParams = TlsCryptoParameters(context) + val sigAlgs = signatureAlgorithms.filterIsInstance() + val sigAlg = sigAlgs.firstOrNull { it.signature == signatureType && it.hash == HashAlgorithm.sha256 } + ?: sigAlgs.firstOrNull { it.signature == signatureType } + ?: sigAlgs.first() + return BcDefaultTlsCredentialedSigner(cryptoParams, certificate.crypto, certificate.key, certif, sigAlg) + } + + /** + * exportKeyingMaterial() is only valid inside notifyHandshakeComplete callback + */ + fun exportSrtpKeys(context: TlsContext): ByteArray { + return context.exportKeyingMaterial(ExporterLabel.dtls_srtp, null, 2 * (KEY_LENGTH + SALT_LENGTH)) + } + + /** + * RFC 5764 4.2: exported keying material layout for AES_CM_128_HMAC_SHA1_80 + * [clientMasterKey(16) | serverMasterKey(16) | clientMasterSalt(14) | serverMasterSalt(14)] + * The client protects the packets that it sends with the client keys and the server with the server keys. + * + * @param isClient true if we are the DTLS client + * @return keys used to protect the packets that we send + */ + fun deriveSrtpKeys(exportedKeys: ByteArray, isClient: Boolean): SrtpKeys { + val keyOffset = if (isClient) 0 else KEY_LENGTH + val saltOffset = 2 * KEY_LENGTH + if (isClient) 0 else SALT_LENGTH + val masterKey = exportedKeys.copyOfRange(keyOffset, keyOffset + KEY_LENGTH) + val masterSalt = exportedKeys.copyOfRange(saltOffset, saltOffset + SALT_LENGTH) + return SrtpKeys( + rtp = deriveCryptoProperties(masterKey, masterSalt, rtcp = false), + rtcp = deriveCryptoProperties(masterKey, masterSalt, rtcp = true) + ) + } + + private fun validateX509(cert: TlsCertificate) { + val cf = CertificateFactory.getInstance("X.509") + val bis = ByteArrayInputStream(cert.encoded) + while (bis.available() > 0) { + (cf.generateCertificate(bis) as X509Certificate).checkValidity() + } + } + + private fun computeFingerprint(cert: TlsCertificate): String { + val digest = MessageDigest.getInstance("SHA-256").digest(cert.encoded) + return digest.joinToString(":") { "%02X".format(it) } + } + + // RFC 3711 4.3.1: derive SRTP session keys from master key + master salt via AES-CM PRF. + // SRTP labels: 0x00 → cipher key, 0x01 → auth key, 0x02 → session salt + // SRTCP labels (RFC 3711 4.3.2): 0x03 → cipher key, 0x04 → auth key, 0x05 → session salt + // x = master_salt with byte[7] XOR'd with the label (label * 2^48 in 112-bit representation) + private fun deriveCryptoProperties(masterKey: ByteArray, masterSalt: ByteArray, rtcp: Boolean): CryptoProperties { + val base = if (rtcp) 0x03 else 0x00 + val cipherKey = deriveKey(masterKey, masterSalt, (base + 0).toByte(), 16) + val authKey = deriveKey(masterKey, masterSalt, (base + 1).toByte(), 20) + val salt = deriveKey(masterKey, masterSalt, (base + 2).toByte(), 14) + return CryptoProperties(authKey, cipherKey, salt) + } + + private fun deriveKey(masterKey: ByteArray, masterSalt: ByteArray, label: Byte, lengthBytes: Int): ByteArray { + val x = masterSalt.copyOf(14) + x[7] = (x[7].toInt() xor label.toInt()).toByte() + val iv = ByteArray(16).also { System.arraycopy(x, 0, it, 0, 14) } + val cipher = Cipher.getInstance("AES/ECB/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(masterKey, "AES")) + val result = ByteArray(lengthBytes) + var offset = 0 + var counter = 0 + while (offset < lengthBytes) { + iv[14] = (counter shr 8).toByte() + iv[15] = counter.toByte() + val block = cipher.doFinal(iv) + val toCopy = minOf(16, lengthBytes - offset) + System.arraycopy(block, 0, result, offset, toCopy) + offset += toCopy + counter++ + } + return result + } +} diff --git a/whip/src/main/java/com/pedro/whip/dtls/SrtpKeys.kt b/whip/src/main/java/com/pedro/whip/dtls/SrtpKeys.kt new file mode 100644 index 0000000000..f39c23fb38 --- /dev/null +++ b/whip/src/main/java/com/pedro/whip/dtls/SrtpKeys.kt @@ -0,0 +1,30 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.whip.dtls + +import com.pedro.rtsp.utils.CryptoProperties + +/** + * Session keys derived from the same master key and master salt. + * SRTP and SRTCP use different labels (RFC 3711 4.3.1 and 4.3.2) + */ +data class SrtpKeys( + val rtp: CryptoProperties, + val rtcp: CryptoProperties +) diff --git a/whip/src/main/java/com/pedro/whip/webrtc/SdpInfo.kt b/whip/src/main/java/com/pedro/whip/webrtc/SdpInfo.kt index a788b0823c..3d55ba679f 100644 --- a/whip/src/main/java/com/pedro/whip/webrtc/SdpInfo.kt +++ b/whip/src/main/java/com/pedro/whip/webrtc/SdpInfo.kt @@ -25,5 +25,6 @@ data class SdpInfo( val uFrag: String, val uPass: String, val fingerprint: String, - val candidates: List + val candidates: List, + val isActiveMode: Boolean = false ) diff --git a/whip/src/main/java/com/pedro/whip/webrtc/SdpParser.kt b/whip/src/main/java/com/pedro/whip/webrtc/SdpParser.kt index d00b58f21d..fd412a532e 100644 --- a/whip/src/main/java/com/pedro/whip/webrtc/SdpParser.kt +++ b/whip/src/main/java/com/pedro/whip/webrtc/SdpParser.kt @@ -29,7 +29,8 @@ object SdpParser { val uPass = extractContent(body, "a=ice-pwd:") val fingerprint = extractContent(body, "a=fingerprint:sha-256") val candidates = extractCandidates(body) - return SdpInfo(uFrag, uPass, fingerprint, candidates) + val isActiveMode = extractOptionalContent(body, "a=setup:").equals("active", ignoreCase = true) + return SdpInfo(uFrag, uPass, fingerprint, candidates, isActiveMode) } private fun extractContent(sdp: String, content: String): String { @@ -37,6 +38,11 @@ object SdpParser { .map { it.trim() }.last { it.startsWith(content, ignoreCase = true) }.removePrefix(content).trim() } + private fun extractOptionalContent(sdp: String, content: String): String? { + return sdp.lines() + .map { it.trim() }.lastOrNull { it.startsWith(content, ignoreCase = true) }?.substring(content.length)?.trim() + } + private fun extractCandidates(sdp: String): List { val prefix = "a=candidate:" return sdp.lines() diff --git a/whip/src/test/java/com/pedro/whip/dtls/DtlsUtilsTest.kt b/whip/src/test/java/com/pedro/whip/dtls/DtlsUtilsTest.kt new file mode 100644 index 0000000000..cdda0d75ab --- /dev/null +++ b/whip/src/test/java/com/pedro/whip/dtls/DtlsUtilsTest.kt @@ -0,0 +1,65 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.whip.dtls + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class DtlsUtilsTest { + + //RFC 3711 B.3 key derivation test vectors + private val masterKey = byteArrayOf(0xE1.toByte(), 0xF9.toByte(), 0x7A, 0x0D, 0x3E, 0x01, 0x8B.toByte(), 0xE0.toByte(), 0xD6.toByte(), 0x4F, 0xA3.toByte(), 0x2C, 0x06, 0xDE.toByte(), 0x41, 0x39) + private val masterSalt = byteArrayOf(0x0E, 0xC6.toByte(), 0x75, 0xAD.toByte(), 0x49, 0x8A.toByte(), 0xFE.toByte(), 0xEB.toByte(), 0xB6.toByte(), 0x96.toByte(), 0x0B, 0x3A, 0xAB.toByte(), 0xE6.toByte()) + private val rtpCipherKey = byteArrayOf(0xC6.toByte(), 0x1E, 0x7A, 0x93.toByte(), 0x74, 0x4F, 0x39, 0xEE.toByte(), 0x10, 0x73, 0x4A, 0xFE.toByte(), 0x3F, 0xF7.toByte(), 0xA0.toByte(), 0x87.toByte()) + private val rtpSalt = byteArrayOf(0x30, 0xCB.toByte(), 0xBC.toByte(), 0x08, 0x86.toByte(), 0x3D, 0x8C.toByte(), 0x85.toByte(), 0xD4.toByte(), 0x9D.toByte(), 0xB3.toByte(), 0x4A, 0x9A.toByte(), 0xE1.toByte()) + private val rtpAuthKey = byteArrayOf(0xCE.toByte(), 0xBE.toByte(), 0x32, 0x1F, 0x6F, 0xF7.toByte(), 0x71, 0x6B, 0x6F, 0xD4.toByte(), 0xAB.toByte(), 0x49, 0xAF.toByte(), 0x25, 0x6A, 0x15, 0x6D, 0x38, 0xBA.toByte(), 0xA4.toByte()) + //same master key and salt with the SRTCP labels 0x03, 0x04 and 0x05 (RFC 3711 4.3.2), calculated with openssl + private val rtcpCipherKey = byteArrayOf(0x4C, 0x1A, 0xA4.toByte(), 0x5A, 0x81.toByte(), 0xF7.toByte(), 0x3D, 0x61, 0xC8.toByte(), 0x00, 0xBB.toByte(), 0xB0.toByte(), 0x0F, 0xBB.toByte(), 0x1E, 0xAA.toByte()) + private val rtcpSalt = byteArrayOf(0x95.toByte(), 0x81.toByte(), 0xC7.toByte(), 0xAD.toByte(), 0x87.toByte(), 0xB3.toByte(), 0xE5.toByte(), 0x30, 0xBF.toByte(), 0x3E, 0x44, 0x54, 0xA8.toByte(), 0xB3.toByte()) + private val rtcpAuthKey = byteArrayOf(0x8D.toByte(), 0x54, 0x53, 0x4F, 0xEB.toByte(), 0x49, 0xAE.toByte(), 0x8E.toByte(), 0x79, 0x93.toByte(), 0xA6.toByte(), 0xBD.toByte(), 0x0B, 0x84.toByte(), 0x4F, 0xC3.toByte(), 0x23, 0xA9.toByte(), 0x3D, 0xFD.toByte()) + //master key and salt of the other side + private val otherKey = byteArrayOf(0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11) + private val otherSalt = byteArrayOf(0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22) + + @Test + fun `GIVEN exported keys WHEN derive keys as client THEN use client master key and salt`() { + //[clientMasterKey | serverMasterKey | clientMasterSalt | serverMasterSalt] + val exportedKeys = masterKey + otherKey + masterSalt + otherSalt + val keys = DtlsUtils.deriveSrtpKeys(exportedKeys, isClient = true) + assertArrayEquals(rtpCipherKey, keys.rtp.sessionKey) + assertArrayEquals(rtpAuthKey, keys.rtp.authKey) + assertArrayEquals(rtpSalt, keys.rtp.salt) + assertArrayEquals(rtcpCipherKey, keys.rtcp.sessionKey) + assertArrayEquals(rtcpAuthKey, keys.rtcp.authKey) + assertArrayEquals(rtcpSalt, keys.rtcp.salt) + } + + @Test + fun `GIVEN exported keys WHEN derive keys as server THEN use server master key and salt`() { + //[clientMasterKey | serverMasterKey | clientMasterSalt | serverMasterSalt] + val exportedKeys = otherKey + masterKey + otherSalt + masterSalt + val keys = DtlsUtils.deriveSrtpKeys(exportedKeys, isClient = false) + assertArrayEquals(rtpCipherKey, keys.rtp.sessionKey) + assertArrayEquals(rtpAuthKey, keys.rtp.authKey) + assertArrayEquals(rtpSalt, keys.rtp.salt) + assertArrayEquals(rtcpCipherKey, keys.rtcp.sessionKey) + assertArrayEquals(rtcpAuthKey, keys.rtcp.authKey) + assertArrayEquals(rtcpSalt, keys.rtcp.salt) + } +} diff --git a/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt b/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt new file mode 100644 index 0000000000..68f8bec33b --- /dev/null +++ b/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt @@ -0,0 +1,74 @@ +/* + * + * * Copyright (C) 2024 pedroSG94. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package com.pedro.whip.webrtc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SdpParserTest { + + private val sessionHeader = "v=0\r\n" + + "o=- 1 2 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + private val mediaInfo = "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=ice-ufrag:94441946\r\n" + + "a=ice-pwd:37b9579j5pr97o6a86sm205436991m8k\r\n" + + "a=fingerprint:sha-256 D8:9F:3B:9D:05:F6:C2:84:6C:DD:DD:F2:C3:3A:21:EE:AD:9D:57:4B:A2:24:AC:88:A2:E0:1F:DF:95:70:21:A2\r\n" + private val candidate = "a=candidate:0 1 udp 2130706431 10.0.2.2 8000 typ host generation 0\r\n" + + @Test + fun `GIVEN ice-lite passive answer WHEN parse THEN get passive setup`() { + val answer = sessionHeader + + "a=ice-lite\r\n" + + mediaInfo + + "a=setup:passive\r\n" + + "a=mid:0\r\n" + + candidate + val info = SdpParser.parseBodyAnswer(answer) + assertEquals("passive", info.setup) + assertEquals("94441946", info.uFrag) + assertEquals("37b9579j5pr97o6a86sm205436991m8k", info.uPass) + assertEquals("D8:9F:3B:9D:05:F6:C2:84:6C:DD:DD:F2:C3:3A:21:EE:AD:9D:57:4B:A2:24:AC:88:A2:E0:1F:DF:95:70:21:A2", info.fingerprint) + assertEquals(1, info.candidates.size) + } + + @Test + fun `GIVEN active answer WHEN parse THEN get active setup`() { + val answer = sessionHeader + mediaInfo + "a=setup:active\r\n" + candidate + val info = SdpParser.parseBodyAnswer(answer) + assertEquals("active", info.setup) + } + + @Test + fun `GIVEN setup in session and media level WHEN parse THEN media level has priority`() { + val answer = sessionHeader + "a=setup:active\r\n" + mediaInfo + "a=setup:passive\r\n" + candidate + val info = SdpParser.parseBodyAnswer(answer) + assertEquals("passive", info.setup) + } + + @Test + fun `GIVEN answer without setup WHEN parse THEN setup is null`() { + val answer = sessionHeader + mediaInfo + candidate + val info = SdpParser.parseBodyAnswer(answer) + assertNull(info.setup) + } +} From 8fa9817e56fee11ebb0a7f71e5e4b668c3f4496d Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 22 Oct 2026 00:48:30 +0200 Subject: [PATCH 4/7] fix tests --- .../src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt b/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt index 68f8bec33b..eb9f9b5778 100644 --- a/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt +++ b/whip/src/test/java/com/pedro/whip/webrtc/SdpParserTest.kt @@ -19,6 +19,7 @@ package com.pedro.whip.webrtc import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Test @@ -44,7 +45,7 @@ class SdpParserTest { "a=mid:0\r\n" + candidate val info = SdpParser.parseBodyAnswer(answer) - assertEquals("passive", info.setup) + assertEquals(false, info.isActiveMode) assertEquals("94441946", info.uFrag) assertEquals("37b9579j5pr97o6a86sm205436991m8k", info.uPass) assertEquals("D8:9F:3B:9D:05:F6:C2:84:6C:DD:DD:F2:C3:3A:21:EE:AD:9D:57:4B:A2:24:AC:88:A2:E0:1F:DF:95:70:21:A2", info.fingerprint) @@ -55,20 +56,20 @@ class SdpParserTest { fun `GIVEN active answer WHEN parse THEN get active setup`() { val answer = sessionHeader + mediaInfo + "a=setup:active\r\n" + candidate val info = SdpParser.parseBodyAnswer(answer) - assertEquals("active", info.setup) + assertEquals(true, info.isActiveMode) } @Test fun `GIVEN setup in session and media level WHEN parse THEN media level has priority`() { val answer = sessionHeader + "a=setup:active\r\n" + mediaInfo + "a=setup:passive\r\n" + candidate val info = SdpParser.parseBodyAnswer(answer) - assertEquals("passive", info.setup) + assertEquals(false, info.isActiveMode) } @Test fun `GIVEN answer without setup WHEN parse THEN setup is null`() { val answer = sessionHeader + mediaInfo + candidate val info = SdpParser.parseBodyAnswer(answer) - assertNull(info.setup) + assertFalse(info.isActiveMode) } } From 25afaaa65183641bd28c67e420139d102b97babf Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 22 Oct 2026 01:24:26 +0200 Subject: [PATCH 5/7] fix close whep --- whip/src/main/java/com/pedro/whip/WhipClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whip/src/main/java/com/pedro/whip/WhipClient.kt b/whip/src/main/java/com/pedro/whip/WhipClient.kt index e158aa91b0..80741c6527 100644 --- a/whip/src/main/java/com/pedro/whip/WhipClient.kt +++ b/whip/src/main/java/com/pedro/whip/WhipClient.kt @@ -430,9 +430,9 @@ class WhipClient(private val connectChecker: ConnectChecker) { } private suspend fun disconnect(clear: Boolean) { - if (isStreaming) whipSender.stop() dtlsConnection?.close() dtlsConnection = null + if (isStreaming) whipSender.stop() val error = runCatching { withTimeoutOrNull(100.milliseconds) { commandsManager.writeDelete() From bc0a2dd4e660920735802d97d5f536ac22ad0762 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 22 Oct 2026 01:29:02 +0200 Subject: [PATCH 6/7] set dtls timeout to 10s --- whip/src/main/java/com/pedro/whip/WhipClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whip/src/main/java/com/pedro/whip/WhipClient.kt b/whip/src/main/java/com/pedro/whip/WhipClient.kt index 80741c6527..564112b9db 100644 --- a/whip/src/main/java/com/pedro/whip/WhipClient.kt +++ b/whip/src/main/java/com/pedro/whip/WhipClient.kt @@ -365,7 +365,7 @@ class WhipClient(private val connectChecker: ConnectChecker) { } }) - val result = withTimeoutOrNull(5_000.milliseconds) { dtlsResult.await() } + val result = withTimeoutOrNull(10_000.milliseconds) { dtlsResult.await() } ?: Result.failure(Exception("timeout")) val srtpKeys = result.getOrNull() if (srtpKeys == null) { From 9881eb61cdc0a9de4dee38cdb1fe37017ab9be3a Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Thu, 22 Oct 2026 01:46:07 +0200 Subject: [PATCH 7/7] bind whep socket instead of connect it --- whip/src/main/java/com/pedro/whip/WhipClient.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/whip/src/main/java/com/pedro/whip/WhipClient.kt b/whip/src/main/java/com/pedro/whip/WhipClient.kt index 564112b9db..fac536f26b 100644 --- a/whip/src/main/java/com/pedro/whip/WhipClient.kt +++ b/whip/src/main/java/com/pedro/whip/WhipClient.kt @@ -271,8 +271,11 @@ class WhipClient(private val connectChecker: ConnectChecker) { val host = remoteCandidates[0].getRealHost() val port = remoteCandidates[0].getRealPort() - val socket = StreamSocket.createUdpSocket(socketType, host, port, socketTimeout) - socket.connect() + //bind socket instead of connect to avoid ignore packets received from other port + val bindHost = if (host.contains(":")) "::" else "0.0.0.0" + val socket = StreamSocket.createUdpSocket(socketType, bindHost, 0, socketTimeout) + socket.bind() + socket.setRemoteAddress(host, port) val dtlsTransport = DtlsTransport(socket) val requestId = commandsManager.generateTransactionId()