C7ChirpTransportTest.kt

7.7 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.assertTrue13import org.junit.Test14import java.io.PipedInputStream15import java.io.PipedOutputStream16import java.security.SecureRandom1718/**19 * C7 transport tests. Mirrors the existing ChirpSyncTransportTest idiom: plain20 * JUnit + runBlocking, no Robolectric. PairedDeviceStore itself needs an Android21 * Context (EncryptedSharedPreferences), so its persistence is exercised by the22 * orchestrator's instrumentation tests; here we test the pure-JVM serialization23 * layer the store round-trips through (test 1) plus its upsert/replace semantics24 * against an in-memory list (test 2), the frame codec (test 3), the full25 * Noise-NK-over-frames exchange the server/client share (test 4), and the26 * timeout/error path (test 5).27 */28class 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    /** Test 1: PairedDevice list survives JSON round-trip (the store's wire form). */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    /** Test 2: upsert replaces an existing entry by deviceId (store semantics). */60    @Test61    fun `upsert by deviceId replaces rather than duplicates`() {62        // Mirror PairedDeviceStore.upsert's pure list transform.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    /** Test 3: writeFrame/readFrame round-trip across payload sizes. */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    /**88     * Test 4: a full Noise NK handshake + identity frame exchange over a Piped89     * socket pair — the exact wire the server (responder) and client (initiator)90     * share. Proves the handshake completes, both sides derive the SAME SAS, and91     * an encrypted identity frame decrypts correctly. No real network.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 initiator handshake — the142     * NK auth tag rejects a MITM / mismatched key, which connectAndSync surfaces143     * as PairingError.HANDSHAKE_FAILED.144     */145    @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        // The initiator pins the WRONG static key, so the responder rejects msg1 (its156        // own ES differs) and never sends msg2. Close the responder's write end on157        // failure so the initiator's receive() hits EOF and fails fast instead of158        // blocking forever — the raw-socket analogue is the soTimeout connectAndSync159        // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking160        // pipe read, so EOF is the deterministic way to unblock it.)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}