C7ChirpTransportTest.kt

8.5 KB · raw

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.SecureRandom1819/**20 * C7 transport tests. Mirrors the existing ChirpSyncTransportTest idiom: plain21 * JUnit + runBlocking, no Robolectric. PairedDeviceStore itself needs an Android22 * Context (EncryptedSharedPreferences), so its persistence is exercised by the23 * orchestrator's instrumentation tests; here we test the pure-JVM serialization24 * layer the store round-trips through (test 1) plus its upsert/replace semantics25 * against an in-memory list (test 2), the frame codec (test 3), the first-pair26 * Noise-NK primitive over frames (tests 4–5), and the local-state key pin used27 * by the live KK reconnect discovery path (test 6).28 */29class 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    /** Test 1: PairedDevice list survives JSON round-trip (the store's wire form). */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    /** Test 2: upsert replaces an existing entry by deviceId (store semantics). */61    @Test62    fun `upsert by deviceId replaces rather than duplicates`() {63        // Mirror PairedDeviceStore.upsert's pure list transform.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    /** Test 3: writeFrame/readFrame round-trip across payload sizes. */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    /**89     * Test 4: the first-pair Noise NK primitive + identity frame exchange over90     * piped frames. Live reconnect uses KK; this pins the separate first-pair91     * pattern that remains valid when the initiator is not known yet.92     */93    @Test94    fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {95        val (responderPriv, responderPub) = newKeyPair()9697        // Cross-wired pipes: initiator's out -> responder's in and vice versa.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()) // server identity (discarded here)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    /**141     * Test 5: a wrong responder static key fails the first-pair NK primitive.142     */143    @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        // The initiator pins the WRONG static key, so the responder rejects msg1 (its154        // own ES differs) and never sends msg2. Close the responder's write end on155        // failure so the initiator's receive() hits EOF and fails fast instead of156        // blocking forever — the raw-socket analogue is the soTimeout connectAndSync157        // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking158        // pipe read, so EOF is the deterministic way to unblock it.)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}