desktop: add LZString and call frame cipher

This commit is contained in:
shum committed 2026-10-22 11:41:54 +00:00
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
@@ -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
}
}
@@ -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++
}
}
}
}
@@ -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"
}
]