1package com.peachpasswords.sync23import kotlinx.coroutines.Dispatchers4import kotlinx.coroutines.launch5import kotlinx.coroutines.runBlocking6import kotlinx.serialization.builtins.ListSerializer7import kotlinx.serialization.json.Json8import org.bouncycastle.crypto.params.X25519PrivateKeyParameters9import org.junit.Assert.assertArrayEquals10import org.junit.Assert.assertEquals11import org.junit.Assert.assertNotNull12import org.junit.Assert.assertTrue13import org.junit.Test14import java.io.PipedInputStream15import java.io.PipedOutputStream16import java.security.SecureRandom171819202122232425262728class C7ChirpTransportTest {2930 private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }31 private val listSerializer = ListSerializer(PairedDevice.serializer())3233 private fun newKeyPair(): Pair<ByteArray, ByteArray> {34 val priv = X25519PrivateKeyParameters(SecureRandom())35 return priv.encoded to priv.generatePublicKey().encoded36 }3738 private fun device(id: String, name: String = "Peer") = PairedDevice(39 deviceId = id,40 deviceName = name,41 deviceType = "android",42 longTermPublicKeyHex = "ab".repeat(32),43 trustSource = "direct",44 pairedAt = 1_000L,45 lastSyncedAt = null,46 )4748 49 @Test50 fun `paired device list round-trips through json`() {51 val devices = listOf(device("a", "Alice"), device("b", "Bob"))52 val encoded = json.encodeToString(listSerializer, devices)53 val decoded = json.decodeFromString(listSerializer, encoded)54 assertEquals(devices, decoded)55 assertEquals("Alice", decoded[0].deviceName)56 assertEquals("ab".repeat(32), decoded[1].longTermPublicKeyHex)57 }5859 60 @Test61 fun `upsert by deviceId replaces rather than duplicates`() {62 63 fun upsert(list: List<PairedDevice>, d: PairedDevice) =64 list.filterNot { it.deviceId == d.deviceId } + d6566 var list = listOf(device("a", "Alice"))67 list = upsert(list, device("b", "Bob"))68 assertEquals(2, list.size)69 list = upsert(list, device("a", "Alice-renamed"))70 assertEquals(2, list.size)71 assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName)72 }7374 75 @Test76 fun `frame codec round-trips for various sizes`() {77 for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) {78 val payload = ByteArray(size) { (it % 251).toByte() }79 val pin = PipedInputStream(1 shl 18)80 val pout = PipedOutputStream(pin)81 writeFrame(pout, payload)82 val read = readFrame(pin)83 assertArrayEquals("size=$size", payload, read)84 }85 }8687 888990919293 @Test94 fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {95 val (responderPriv, responderPub) = newKeyPair()9697 98 val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)99 val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)100101 val initiator = SocketFrameIO(input = r2i, output = i2rOut)102 val responder = SocketFrameIO(input = i2r, output = r2iOut)103104 val localId = LocalChirpIdentity(105 deviceId = "client-1",106 deviceName = "Client",107 deviceType = DeviceType.ANDROID,108 longTermPublicKeyHex = "cd".repeat(32),109 signingPublicKeyHex = "ef".repeat(32),110 )111 val serverId = localId.copy(deviceId = "server-1", deviceName = "Server")112113 var initiatorSas: String? = null114 var responderSas: String? = null115 var receivedByServer: PairIdentity? = null116117 val responderJob = launch(Dispatchers.IO) {118 val session = noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive)119 receivedByServer = parsePairIdentityFrame(session.decrypt(responder.receive()))120 responder.send(session.encrypt(encodePairIdentityFrame(serverId)))121 responderSas = SASDerivation.derive(session.handshakeHash)122 }123124 val initiatorJob = launch(Dispatchers.IO) {125 val session = noiseNKInitiator(responderPub, initiator::send, initiator::receive)126 initiator.send(session.encrypt(encodePairIdentityFrame(localId)))127 session.decrypt(initiator.receive()) 128 initiatorSas = SASDerivation.derive(session.handshakeHash)129 }130131 initiatorJob.join()132 responderJob.join()133134 assertNotNull(initiatorSas)135 assertEquals("both sides derive the same SAS", initiatorSas, responderSas)136 assertEquals("client-1", receivedByServer?.deviceId)137 assertEquals("cd".repeat(32), receivedByServer?.longTermPublicKeyHex)138 }139140 141142143144145 @Test146 fun `handshake fails when responder static key is wrong`() = runBlocking {147 val (responderPriv, responderPub) = newKeyPair()148 val (_, wrongPub) = newKeyPair()149150 val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)151 val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)152 val initiator = SocketFrameIO(input = r2i, output = i2rOut)153 val responder = SocketFrameIO(input = i2r, output = r2iOut)154155 156 157 158 159 160 161 val responderJob = launch(Dispatchers.IO) {162 runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) }163 runCatching { r2iOut.close() }164 }165166 var initiatorFailed = false167 val initiatorJob = launch(Dispatchers.IO) {168 try {169 noiseNKInitiator(wrongPub, initiator::send, initiator::receive)170 } catch (_: Throwable) {171 initiatorFailed = true172 }173 }174175 initiatorJob.join()176 responderJob.join()177 assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)178 }179}