diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index e9503bc6ce..a72ad2b0f1 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -15,6 +15,18 @@ val simplexAssetsDir = rootProject.findProperty("simplex.assets.dir") as String? val simplexAssetsLocal = file("src/commonMain/resources/assets/simplex") val hasSimplexAssets = simplexAssetsDir != null +val webrtcJavaVersion = "0.19.0" +// Desktop packages are built on the target machine, so only the host's native library is bundled +val webrtcJavaNativeClassifier: String = run { + val os = System.getProperty("os.name").lowercase() + val arch = if (System.getProperty("os.arch") in listOf("aarch64", "arm64")) "aarch64" else "x86_64" + when { + os.contains("win") -> "windows-$arch" + os.contains("mac") -> "macos-$arch" + else -> "linux-$arch" + } +} + if (simplexAssetsDir != null) { val resolvedAssetsDir = rootProject.rootDir.resolve(simplexAssetsDir) val srcImagesDir = resolvedAssetsDir.resolve("multiplatform/resources/MR/images") @@ -144,6 +156,8 @@ kotlin { implementation("net.java.dev.jna:jna:5.14.0") implementation(project(":external:nanohttpd")) implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("dev.onvoid.webrtc:webrtc-java:$webrtcJavaVersion") + implementation("dev.onvoid.webrtc:webrtc-java:$webrtcJavaVersion:$webrtcJavaNativeClassifier") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallFrameCipher.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallFrameCipher.kt new file mode 100644 index 0000000000..cda0931b61 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallFrameCipher.kt @@ -0,0 +1,68 @@ +package chat.simplex.common.views.call + +import dev.onvoid.webrtc.RTCEncodedFrame +import dev.onvoid.webrtc.RTCEncodedVideoFrame +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +// Frame layout of call.ts encryptFrame/decryptFrame: clear codec header, AES-256-GCM ciphertext with tag, 12-byte IV. +// The header stays in the clear because packetizers read it: VP8 key frames 10 bytes, VP8 delta frames 3, audio (Opus TOC) 1. +internal class CallFrameCipher(aesKey: String, private val random: SecureRandom = SecureRandom()) { + private val key = SecretKeySpec(Base64.getUrlDecoder().decode(aesKey), "AES") + + fun encrypt(frame: RTCEncodedFrame) { + val data = frameData(frame) + frame.setData(encrypt(data, clearBytes(frame, data.size))) + } + + // A frame that fails authentication is dropped, never passed to the decoder. + fun decrypt(frame: RTCEncodedFrame) { + val data = frameData(frame) + val plaintext = decrypt(data, clearBytes(frame, data.size)) + if (plaintext == null) frame.drop() else frame.setData(plaintext) + } + + fun encrypt(data: ByteArray, clear: Int): ByteArray { + val iv = ByteArray(IV_LENGTH).also(random::nextBytes) + val ciphertext = if (data.size > clear) gcm(Cipher.ENCRYPT_MODE, iv).doFinal(data, clear, data.size - clear) else ByteArray(0) + return data.copyOfRange(0, clear) + ciphertext + iv + } + + fun decrypt(data: ByteArray, clear: Int): ByteArray? { + if (data.size < clear + IV_LENGTH) return null + val ciphertextLength = data.size - clear - IV_LENGTH + if (ciphertextLength == 0) return data.copyOfRange(0, clear) + val iv = data.copyOfRange(data.size - IV_LENGTH, data.size) + return try { + data.copyOfRange(0, clear) + gcm(Cipher.DECRYPT_MODE, iv).doFinal(data, clear, ciphertextLength) + } catch (e: AEADBadTagException) { + null + } + } + + private fun gcm(mode: Int, iv: ByteArray): Cipher = + Cipher.getInstance("AES/GCM/NoPadding").apply { init(mode, key, GCMParameterSpec(TAG_BITS, iv)) } + + private fun frameData(frame: RTCEncodedFrame): ByteArray = ByteArray(frame.size).also { frame.data.get(it) } + + private fun clearBytes(frame: RTCEncodedFrame, size: Int): Int { + val clear = when { + frame !is RTCEncodedVideoFrame -> AUDIO_CLEAR_BYTES + frame.isKeyFrame -> VIDEO_KEY_FRAME_CLEAR_BYTES + else -> VIDEO_DELTA_FRAME_CLEAR_BYTES + } + return minOf(clear, size) + } + + companion object { + const val IV_LENGTH = 12 + private const val TAG_BITS = 128 + const val AUDIO_CLEAR_BYTES = 1 + const val VIDEO_KEY_FRAME_CLEAR_BYTES = 10 + const val VIDEO_DELTA_FRAME_CLEAR_BYTES = 3 + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/LZString.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/LZString.kt new file mode 100644 index 0000000000..80abc6ddcb --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/LZString.kt @@ -0,0 +1,177 @@ +package chat.simplex.common.views.call + +// Port of lz-string compressToBase64/decompressFromBase64, operating on UTF-16 code units as the JS library does. +// Call peers (call.ts, iOS) exchange SDP and ICE candidates in this encoding. +internal object LZString { + private const val KEY_BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" + private val BASE64_VALUES = IntArray(128) { -1 }.also { t -> KEY_BASE64.forEachIndexed { i, c -> t[c.code] = i } } + private const val BASE64_BITS = 6 + private const val BASE64_RESET_VALUE = 32 + + fun compressToBase64(input: String): String { + val res = compress(input, BASE64_BITS) { KEY_BASE64[it] } + return when (res.length % 4) { + 1 -> "$res===" + 2 -> "$res==" + 3 -> "$res=" + else -> res + } + } + + fun decompressFromBase64(input: String): String? { + if (input.isEmpty()) return null + return decompress(input.length, BASE64_RESET_VALUE) { i -> input[i].code.let { if (it < 128) BASE64_VALUES[it] else -1 } } + } + + private class BitWriter(private val bitsPerChar: Int, private val charOf: (Int) -> Char) { + val out = StringBuilder() + private var value = 0 + private var position = 0 + + fun write(bits: Int, v: Int) { + var x = v + repeat(bits) { + value = (value shl 1) or (x and 1) + if (position == bitsPerChar - 1) { + position = 0 + out.append(charOf(value)) + value = 0 + } else { + position++ + } + x = x shr 1 + } + } + + fun flush() { + while (true) { + value = value shl 1 + if (position == bitsPerChar - 1) { + out.append(charOf(value)) + break + } + position++ + } + } + } + + private fun compress(input: String, bitsPerChar: Int, charOf: (Int) -> Char): String { + val dictionary = HashMap() + val toCreate = HashSet() + var w = "" + var enlargeIn = 2 + var dictSize = 3 + var numBits = 2 + val writer = BitWriter(bitsPerChar, charOf) + + fun enlarge() { + enlargeIn-- + if (enlargeIn == 0) { + enlargeIn = 1 shl numBits + numBits++ + } + } + + fun emitW() { + if (w in toCreate) { + val code = w[0].code + if (code < 256) { + writer.write(numBits, 0) + writer.write(8, code) + } else { + writer.write(numBits, 1) + writer.write(16, code) + } + enlarge() + toCreate.remove(w) + } else { + writer.write(numBits, dictionary.getValue(w)) + } + enlarge() + } + + for (ch in input) { + val c = ch.toString() + if (c !in dictionary) { + dictionary[c] = dictSize++ + toCreate.add(c) + } + val wc = w + c + if (wc in dictionary) { + w = wc + } else { + emitW() + dictionary[wc] = dictSize++ + w = c + } + } + if (w.isNotEmpty()) emitW() + writer.write(numBits, 2) + writer.flush() + return writer.out.toString() + } + + private fun decompress(length: Int, resetValue: Int, valueAt: (Int) -> Int): String? { + val dictionary = arrayListOf("0", "1", "2") + var enlargeIn = 4 + var numBits = 3 + var dataVal = valueAt(0) + var dataPosition = resetValue + var dataIndex = 1 + + fun readBits(n: Int): Int { + var bits = 0 + var power = 1 + repeat(n) { + val resb = dataVal and dataPosition + dataPosition = dataPosition shr 1 + if (dataPosition == 0) { + dataPosition = resetValue + dataVal = if (dataIndex < length) valueAt(dataIndex) else 0 + dataIndex++ + } + if (resb > 0) bits = bits or power + power = power shl 1 + } + return bits + } + + val first = when (readBits(2)) { + 0 -> readBits(8).toChar().toString() + 1 -> readBits(16).toChar().toString() + else -> return "" + } + dictionary.add(first) + var w = first + val result = StringBuilder(first) + while (true) { + if (dataIndex > length) return "" + var c = readBits(numBits) + when (c) { + 0, 1 -> { + dictionary.add(readBits(if (c == 0) 8 else 16).toChar().toString()) + c = dictionary.size - 1 + enlargeIn-- + } + 2 -> return result.toString() + } + if (enlargeIn == 0) { + enlargeIn = 1 shl numBits + numBits++ + } + val entry = when { + c < dictionary.size -> dictionary[c] + c == dictionary.size -> w + w[0] + else -> return null + } + result.append(entry) + dictionary.add(w + entry[0]) + enlargeIn-- + w = entry + if (enlargeIn == 0) { + enlargeIn = 1 shl numBits + numBits++ + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallFrameCipherTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallFrameCipherTest.kt new file mode 100644 index 0000000000..31866beece --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallFrameCipherTest.kt @@ -0,0 +1,96 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.CallFrameCipher +import chat.simplex.common.views.call.CallFrameCipher.Companion.AUDIO_CLEAR_BYTES +import chat.simplex.common.views.call.CallFrameCipher.Companion.IV_LENGTH +import chat.simplex.common.views.call.CallFrameCipher.Companion.VIDEO_DELTA_FRAME_CLEAR_BYTES +import chat.simplex.common.views.call.CallFrameCipher.Companion.VIDEO_KEY_FRAME_CLEAR_BYTES +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.test.* + +// Fixtures in resources/call/frames.json were encrypted by callCryptoFunction from the shipped call.js +class CallFrameCipherTest { + @Serializable + private data class Frame(val type: String, val plaintext: String, val encrypted: String) + + @Serializable + private data class Fixture(val aesKey: String, val frames: List) + + private val fixture = Json.decodeFromString(Fixture.serializer(), javaClass.getResource("/call/frames.json")!!.readText()) + private val otherKey = "a6qN3UtBYgo8Hvn2kSy4uHvJbcHXRly4o64aBW5BlpI=" + private val tagBytes = 16 + + private fun clearBytes(type: String, size: Int) = minOf( + when (type) { + "key" -> VIDEO_KEY_FRAME_CLEAR_BYTES + "delta" -> VIDEO_DELTA_FRAME_CLEAR_BYTES + else -> AUDIO_CLEAR_BYTES + }, + size + ) + + private fun String.hex(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun testDecryptsFramesEncryptedByCallJs() { + val cipher = CallFrameCipher(fixture.aesKey) + fixture.frames.forEach { f -> + val encrypted = f.encrypted.hex() + val plaintext = f.plaintext.hex() + val decrypted = cipher.decrypt(encrypted, clearBytes(f.type, plaintext.size)) + assertNotNull(decrypted, "${f.type} frame of ${plaintext.size} bytes") + assertContentEquals(plaintext, decrypted, "${f.type} frame of ${plaintext.size} bytes") + } + } + + @Test + fun testEncryptedLayoutMatchesCallJs() { + val cipher = CallFrameCipher(fixture.aesKey) + fixture.frames.forEach { f -> + val plaintext = f.plaintext.hex() + val clear = clearBytes(f.type, plaintext.size) + val encrypted = cipher.encrypt(plaintext, clear) + assertEquals(f.encrypted.length / 2, encrypted.size, "${f.type} frame of ${plaintext.size} bytes has the call.js size") + assertContentEquals(plaintext.copyOfRange(0, clear), encrypted.copyOfRange(0, clear), "clear header of ${f.type} frame") + val expectedSize = if (plaintext.size > clear) plaintext.size + tagBytes + IV_LENGTH else plaintext.size + IV_LENGTH + assertEquals(expectedSize, encrypted.size) + assertContentEquals(plaintext, cipher.decrypt(encrypted, clear)) + } + } + + @Test + fun testEncryptionUsesFreshIv() { + val cipher = CallFrameCipher(fixture.aesKey) + val plaintext = ByteArray(100) { it.toByte() } + val first = cipher.encrypt(plaintext, VIDEO_DELTA_FRAME_CLEAR_BYTES) + val second = cipher.encrypt(plaintext, VIDEO_DELTA_FRAME_CLEAR_BYTES) + assertFalse(first.contentEquals(second)) + } + + @Test + fun testRejectsFramesWithWrongKey() { + val cipher = CallFrameCipher(otherKey) + fixture.frames.filter { it.plaintext.length / 2 > clearBytes(it.type, Int.MAX_VALUE) }.forEach { f -> + assertNull(cipher.decrypt(f.encrypted.hex(), clearBytes(f.type, f.plaintext.length / 2)), "${f.type} frame decrypted with a wrong key") + } + } + + @Test + fun testRejectsTamperedFrames() { + val cipher = CallFrameCipher(fixture.aesKey) + val f = fixture.frames.first { it.type == "key" } + val clear = clearBytes(f.type, f.plaintext.length / 2) + val encrypted = f.encrypted.hex() + for (i in listOf(clear, encrypted.size - IV_LENGTH - 1, encrypted.size - 1)) { + val tampered = encrypted.copyOf().also { it[i] = (it[i].toInt() xor 1).toByte() } + assertNull(cipher.decrypt(tampered, clear), "byte $i flipped") + } + } + + @Test + fun testRejectsTruncatedFrames() { + val cipher = CallFrameCipher(fixture.aesKey) + assertNull(cipher.decrypt(ByteArray(VIDEO_KEY_FRAME_CLEAR_BYTES + IV_LENGTH - 1), VIDEO_KEY_FRAME_CLEAR_BYTES)) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LZStringTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LZStringTest.kt new file mode 100644 index 0000000000..4540a5a286 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LZStringTest.kt @@ -0,0 +1,50 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.LZString +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +// Fixtures in resources/call/lzstring.json were produced by the lz-string.min.js shipped with call.js +class LZStringTest { + @Serializable + private data class Vector(val input: String, val compressed: String) + + private val vectors = Json.decodeFromString( + ListSerializer(Vector.serializer()), + javaClass.getResource("/call/lzstring.json")!!.readText() + ) + + @Test + fun testCompressMatchesJsLibrary() { + vectors.forEach { v -> + assertEquals(v.compressed, LZString.compressToBase64(v.input), "compressing ${v.input.take(40)}") + } + } + + @Test + fun testDecompressJsOutput() { + vectors.filter { it.input.isNotEmpty() }.forEach { v -> + assertEquals(v.input, LZString.decompressFromBase64(v.compressed), "decompressing ${v.compressed.take(40)}") + } + } + + @Test + fun testEmptyInputDecompressesToNull() { + assertNull(LZString.decompressFromBase64("")) + } + + @Test + fun testRoundTripRandomUtf16() { + val random = Random(42) + repeat(200) { i -> + val alphabet = if (i % 2 == 0) 128 else 0x10000 + val input = String(CharArray(random.nextInt(0, 3000)) { random.nextInt(alphabet).toChar() }) + assertEquals(input, LZString.decompressFromBase64(LZString.compressToBase64(input)), "round trip $i, length ${input.length}") + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/resources/call/frames.json b/apps/multiplatform/common/src/desktopTest/resources/call/frames.json new file mode 100644 index 0000000000..7d28497a64 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/resources/call/frames.json @@ -0,0 +1,35 @@ +{ + "aesKey": "6r8EhZnO_fUPaa5xK92wY9qfCr2pk36gq5pgtJd2Ong=", + "frames": [ + { + "type": "key", + "plaintext": "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04294e7398bde2072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e", + "encrypted": "0b30557a9fc4e90e3358724c1046eae327d180c98adaa97d1e0231919af9efbe69e2bb1d9bfe1847bc6f777da2864fced377164141db6134b1a306d45526a81929383868c3e50779d048e7d4085b786efed73787d5d18daba7cc07427e64a24efde6875d6980cc7fdffee1529e9874b3c81d857bcbd8098e2e191188ec146b4e93a76d4eb3051f698f21db02657de7427a097109" + }, + { + "type": "delta", + "plaintext": "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc0126", + "encrypted": "0b3055ad8d9008960612156a4ddd82d470d3934c9ccf59656055d34f2521960c3f50d4247ef33f777b09f4c8c6b7e1a40ad61ab9a5f20a9b14ec5fbba5f1207cd2523443b758dd7f7f3cc0619c02528f42e717dfab405e34e3fb9b94" + }, + { + "type": "audio", + "plaintext": "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489ae", + "encrypted": "0b4fe7211c21a126743733d1bdfb66b277706ce2a3fac42071b684a8832124e986f1d8cfa7a50a561af78a6278e8739da4f67a3fe7bb6d49a8a410152a7afd6ead042082" + }, + { + "type": "key", + "plaintext": "0b30557a9fc4e90e3358", + "encrypted": "0b30557a9fc4e90e3358d00ba01ede103cba07839c12" + }, + { + "type": "delta", + "plaintext": "0b3055", + "encrypted": "0b305560cbfc6cf2a71c58c430b2d7" + }, + { + "type": "audio", + "plaintext": "0b", + "encrypted": "0bf9bb776d04256ff8083a73d4" + } + ] +} diff --git a/apps/multiplatform/common/src/desktopTest/resources/call/lzstring.json b/apps/multiplatform/common/src/desktopTest/resources/call/lzstring.json new file mode 100644 index 0000000000..624ede4641 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/resources/call/lzstring.json @@ -0,0 +1,30 @@ +[ + { + "input": "", + "compressed": "Q===" + }, + { + "input": "a", + "compressed": "IZA=" + }, + { + "input": "aaaaaaaaaaaaaaaaaaaa", + "compressed": "IY1/kA==" + }, + { + "input": "hello world", + "compressed": "BYUwNmD2AEDukCcwBMg=" + }, + { + "input": "привет 🎉 مرحبا", + "compressed": "vwggEQQcIITCCKwghCIABIHg3CR+3QomCEYwQtGCAowQ5GBA" + }, + { + "input": "{\"type\":\"offer\",\"sdp\":\"v=0\\r\\no=- 4611731400430051336 2 IN IP4 127.0.0.1\\r\\ns=-\\r\\nt=0 0\\r\\na=group:BUNDLE 0 1 2 3\\r\\na=msid-semantic: WMS micCamera\\r\\nm=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126\\r\\nc=IN IP4 0.0.0.0\\r\\na=rtcp:9 IN IP4 0.0.0.0\\r\\na=ice-ufrag:abcd\\r\\na=ice-pwd:0123456789abcdefghijkl\\r\\na=fingerprint:sha-256 AB:CD:EF\\r\\na=setup:actpass\\r\\na=mid:0\\r\\na=sendrecv\\r\\na=rtpmap:111 opus/48000/2\\r\\n\"}", + "compressed": "N4IgLgngDgpiBcID2AzFMBOIA0IDOAJlAiAG4C8ADADobUB2S5AtAAQAsAbAIzcDsAZm7tKldgNEBWbgIGdWAJlYBJAHIqACu1bcFfAHSVD+7rQZ4WZ+mCqsadegENyAcwxIArlHgAhAKqqACIAMgCidjqKrAJWzgC2eACWBMx4MHGO1okAxvCsAOoAsgDKrHE5AMKOcZiOVnHkjh4EiUisAJysfoEaAPQAKsHFvQBK/X3FAIIAahoAYjq8rJwCHREAHDqrvJQ6CpxW2eRqmtpG54ax5Bhg2d6dJ8padsbnVzkwzB4oGI4u8I4AEbZAjvbKfKAAdwI8EougE7EknD463aQJBMBQLgAFokAFYAawANlcUIl6C5MFAMOSwPA8NjHMwFEjWJMfPAKoF4KE5lc0mAvADsmAoI48HgruUYfYGM40vQCBgYNlSFcblAMt5eNxWEgoB48L12OtRJRegorCAAL5AA===" + }, + { + "input": "[{\"candidate\":\"candidate:1 1 udp 2122260223 192.168.1.2 51000 typ host generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0,\"usernameFragment\":\"abcd\"}]", + "compressed": "NobwRAxghgdgJgSzlALgUzALkrRz2YCMABCQK5wAOxATITQwGwAMDAzKQJw0B0hjADj48axAKyFmU4igCe1ABYB7AM4piAczQw0AJ1QIlMYszAAaMCqoBZJFjCmLVytYAyCHQEl4aAB5ZmCzIVPRgoAFs0ADF9DUiYFHsoACMIODAAXwBdIA" + } +]