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.assertThrows13import org.junit.Assert.assertTrue14import org.junit.Test15import java.io.PipedInputStream16import java.io.PipedOutputStream17import java.security.SecureRandom181920212223242526272829class C7ChirpTransportTest {3031 private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }32 private val listSerializer = ListSerializer(PairedDevice.serializer())3334 private fun newKeyPair(): Pair<ByteArray, ByteArray> {35 val priv = X25519PrivateKeyParameters(SecureRandom())36 return priv.encoded to priv.generatePublicKey().encoded37 }3839 private fun device(id: String, name: String = "Peer") = PairedDevice(40 deviceId = id,41 deviceName = name,42 deviceType = "android",43 longTermPublicKeyHex = "ab".repeat(32),44 trustSource = "direct",45 pairedAt = 1_000L,46 lastSyncedAt = null,47 )4849 50 @Test51 fun `paired device list round-trips through json`() {52 val devices = listOf(device("a", "Alice"), device("b", "Bob"))53 val encoded = json.encodeToString(listSerializer, devices)54 val decoded = json.decodeFromString(listSerializer, encoded)55 assertEquals(devices, decoded)56 assertEquals("Alice", decoded[0].deviceName)57 assertEquals("ab".repeat(32), decoded[1].longTermPublicKeyHex)58 }5960 61 @Test62 fun `upsert by deviceId replaces rather than duplicates`() {63 64 fun upsert(list: List<PairedDevice>, d: PairedDevice) =65 list.filterNot { it.deviceId == d.deviceId } + d6667 var list = listOf(device("a", "Alice"))68 list = upsert(list, device("b", "Bob"))69 assertEquals(2, list.size)70 list = upsert(list, device("a", "Alice-renamed"))71 assertEquals(2, list.size)72 assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName)73 }7475 76 @Test77 fun `frame codec round-trips for various sizes`() {78 for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) {79 val payload = ByteArray(size) { (it % 251).toByte() }80 val pin = PipedInputStream(1 shl 18)81 val pout = PipedOutputStream(pin)82 writeFrame(pout, payload)83 val read = readFrame(pin)84 assertArrayEquals("size=$size", payload, read)85 }86 }8788 8990919293 @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 141142143 @Test144 fun `handshake fails when responder static key is wrong`() = runBlocking {145 val (responderPriv, responderPub) = newKeyPair()146 val (_, wrongPub) = newKeyPair()147148 val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)149 val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)150 val initiator = SocketFrameIO(input = r2i, output = i2rOut)151 val responder = SocketFrameIO(input = i2r, output = r2iOut)152153 154 155 156 157 158 159 val responderJob = launch(Dispatchers.IO) {160 runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) }161 runCatching { r2iOut.close() }162 }163164 var initiatorFailed = false165 val initiatorJob = launch(Dispatchers.IO) {166 try {167 noiseNKInitiator(wrongPub, initiator::send, initiator::receive)168 } catch (_: Throwable) {169 initiatorFailed = true170 }171 }172173 initiatorJob.join()174 responderJob.join()175 assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)176 }177178 @Test179 fun `reconnect discovery cannot replace the locally paired responder key`() {180 val (_, storedPublic) = newKeyPair()181 val (_, advertisedPublic) = newKeyPair()182 val paired = device("phone").copy(183 longTermPublicKeyHex = pubkeyToHex(storedPublic),184 )185 val advertised = DiscoveredPeer(186 deviceId = paired.deviceId,187 deviceName = "Spoofed endpoint",188 host = "192.0.2.1",189 port = 49142,190 remoteStaticPublicKey = advertisedPublic,191 )192193 val pinned = pinDiscoveredPeerToPairedState(paired, advertised)194195 assertArrayEquals(storedPublic, pinned.remoteStaticPublicKey)196 assertThrows(IllegalArgumentException::class.java) {197 pinDiscoveredPeerToPairedState(198 paired,199 advertised.copy(deviceId = "different-device"),200 )201 }202 }203}