mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 12:57:45 +00:00
desktop: add LZString and call frame cipher
This commit is contained in:
1 parent
cc3f49f794
commit
c823ed5df2
7 files changed
+470
No files matched your search
@@ -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
|
||||
|
||||
+68
@@ -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
|
||||
}
|
||||
}
|
||||
+177
@@ -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<String, Int>()
|
||||
val toCreate = HashSet<String>()
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -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<Frame>)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user