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.assertFalse14import org.junit.Assert.assertTrue15import org.junit.Test16import java.io.PipedInputStream17import java.io.PipedOutputStream18import java.security.SecureRandom192021222324252627282930class C7ChirpTransportTest {3132 private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }33 private val listSerializer = ListSerializer(PairedDevice.serializer())3435 private fun newKeyPair(): Pair<ByteArray, ByteArray> {36 val priv = X25519PrivateKeyParameters(SecureRandom())37 return priv.encoded to priv.generatePublicKey().encoded38 }3940 private fun device(id: String, name: String = "Peer") = PairedDevice(41 deviceId = id,42 deviceName = name,43 deviceType = "android",44 longTermPublicKeyHex = "ab".repeat(32),45 trustSource = "direct",46 pairedAt = 1_000L,47 lastSyncedAt = null,48 )4950 51 @Test52 fun `paired device list round-trips through json`() {53 val devices = listOf(device("a", "Alice"), device("b", "Bob"))54 val encoded = json.encodeToString(listSerializer, devices)55 val decoded = json.decodeFromString(listSerializer, encoded)56 assertEquals(devices, decoded)57 assertEquals("Alice", decoded[0].deviceName)58 assertEquals("ab".repeat(32), decoded[1].longTermPublicKeyHex)59 }6061 62 @Test63 fun `upsert by deviceId replaces rather than duplicates`() {64 65 fun upsert(list: List<PairedDevice>, d: PairedDevice) =66 list.filterNot { it.deviceId == d.deviceId } + d6768 var list = listOf(device("a", "Alice"))69 list = upsert(list, device("b", "Bob"))70 assertEquals(2, list.size)71 list = upsert(list, device("a", "Alice-renamed"))72 assertEquals(2, list.size)73 assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName)74 }7576 77 @Test78 fun `frame codec round-trips for various sizes`() {79 for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) {80 val payload = ByteArray(size) { (it % 251).toByte() }81 val pin = PipedInputStream(1 shl 18)82 val pout = PipedOutputStream(pin)83 writeFrame(pout, payload)84 val read = readFrame(pin)85 assertArrayEquals("size=$size", payload, read)86 }87 }8889 9091929394 @Test95 fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {96 val (responderPriv, responderPub) = newKeyPair()9798 99 val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)100 val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)101102 val initiator = SocketFrameIO(input = r2i, output = i2rOut)103 val responder = SocketFrameIO(input = i2r, output = r2iOut)104105 val localId = LocalChirpIdentity(106 deviceId = "client-1",107 deviceName = "Client",108 deviceType = DeviceType.ANDROID,109 longTermPublicKeyHex = "cd".repeat(32),110 signingPublicKeyHex = "ef".repeat(32),111 )112 val serverId = localId.copy(deviceId = "server-1", deviceName = "Server")113114 var initiatorSas: String? = null115 var responderSas: String? = null116 var receivedByServer: PairIdentity? = null117118 val responderJob = launch(Dispatchers.IO) {119 val session = noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive)120 receivedByServer = parsePairIdentityFrame(session.decrypt(responder.receive()))121 responder.send(session.encrypt(encodePairIdentityFrame(serverId)))122 responderSas = SASDerivation.derive(session.handshakeHash)123 }124125 val initiatorJob = launch(Dispatchers.IO) {126 val session = noiseNKInitiator(responderPub, initiator::send, initiator::receive)127 initiator.send(session.encrypt(encodePairIdentityFrame(localId)))128 session.decrypt(initiator.receive()) 129 initiatorSas = SASDerivation.derive(session.handshakeHash)130 }131132 initiatorJob.join()133 responderJob.join()134135 assertNotNull(initiatorSas)136 assertEquals("both sides derive the same SAS", initiatorSas, responderSas)137 assertEquals("client-1", receivedByServer?.deviceId)138 assertEquals("cd".repeat(32), receivedByServer?.longTermPublicKeyHex)139 }140141 142143144 @Test145 fun `handshake fails when responder static key is wrong`() = runBlocking {146 val (responderPriv, responderPub) = newKeyPair()147 val (_, wrongPub) = newKeyPair()148149 val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)150 val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)151 val initiator = SocketFrameIO(input = r2i, output = i2rOut)152 val responder = SocketFrameIO(input = i2r, output = r2iOut)153154 155 156 157 158 159 160 val responderJob = launch(Dispatchers.IO) {161 runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) }162 runCatching { r2iOut.close() }163 }164165 var initiatorFailed = false166 val initiatorJob = launch(Dispatchers.IO) {167 try {168 noiseNKInitiator(wrongPub, initiator::send, initiator::receive)169 } catch (_: Throwable) {170 initiatorFailed = true171 }172 }173174 initiatorJob.join()175 responderJob.join()176 assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)177 }178179 @Test180 fun `a presented identity cannot replace the locally paired key`() {181 182 183 184 185 186 187 val (_, storedPublic) = newKeyPair()188 val (_, spoofedPublic) = newKeyPair()189 val paired = device("phone").copy(190 longTermPublicKeyHex = pubkeyToHex(storedPublic),191 )192193 val honest = PairIdentity(194 deviceId = paired.deviceId,195 deviceName = "Phone",196 deviceType = DeviceType.ANDROID,197 longTermPublicKeyHex = pubkeyToHex(storedPublic),198 signingPublicKeyHex = null,199 wsPort = null,200 relayUrl = null,201 )202 assertTrue(identityMatchesAuthenticatedPeer(paired, honest, storedPublic))203204 205 assertFalse(206 identityMatchesAuthenticatedPeer(207 paired,208 honest.copy(deviceId = "different-device"),209 storedPublic,210 ),211 )212 213 assertFalse(214 identityMatchesAuthenticatedPeer(215 paired,216 honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),217 spoofedPublic,218 ),219 )220 221 222 assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))223 }224}