C7TransportTest.kt

9.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.assertFalse14import org.junit.Assert.assertTrue15import org.junit.Test16import java.io.PipedInputStream17import java.io.PipedOutputStream18import java.security.SecureRandom1920/**21 * C7 transport tests. Mirrors the existing SyncTransportTest idiom: plain22 * JUnit + runBlocking, no Robolectric. PairedDeviceStore itself needs an Android23 * Context (EncryptedSharedPreferences), so its persistence is exercised by the24 * orchestrator's instrumentation tests; here we test the pure-JVM serialization25 * layer the store round-trips through (test 1) plus its upsert/replace semantics26 * against an in-memory list (test 2), the frame codec (test 3), the first-pair27 * Noise-NK primitive over frames (tests 4–5), and the local-state key pin used28 * by the live KK reconnect discovery path (test 6).29 */30class C7TransportTest {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    /** Test 1: PairedDevice list survives JSON round-trip (the store's wire form). */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    /** Test 2: upsert replaces an existing entry by deviceId (store semantics). */62    @Test63    fun `upsert by deviceId replaces rather than duplicates`() {64        // Mirror PairedDeviceStore.upsert's pure list transform.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    /** Test 3: writeFrame/readFrame round-trip across payload sizes. */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    /**90     * Test 4: the first-pair Noise NK primitive + identity frame exchange over91     * piped frames. Live reconnect uses KK; this pins the separate first-pair92     * pattern that remains valid when the initiator is not known yet.93     */94    @Test95    fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {96        val (responderPriv, responderPub) = newKeyPair()9798        // Cross-wired pipes: initiator's out -> responder's in and vice versa.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 = LocalDeviceIdentity(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()) // server identity (discarded here)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    /**142     * Test 5: a wrong responder static key fails the first-pair NK primitive.143     */144    @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        // The initiator pins the WRONG static key, so the responder rejects msg1 (its155        // own ES differs) and never sends msg2. Close the responder's write end on156        // failure so the initiator's receive() hits EOF and fails fast instead of157        // blocking forever — the raw-socket analogue is the soTimeout connectAndSync158        // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking159        // pipe read, so EOF is the deterministic way to unblock it.)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        // Was `reconnect discovery cannot replace the locally paired responder182        // key`, which pinned a LAN-discovered endpoint. Discovery is gone with183        // LAN sync (docs/sync-lan-retirement.md), but the property it protected184        // is not: a peer's self-asserted PAIR_IDENTITY must never override the185        // paired record, and is only believed when it matches the static key186        // Noise actually authenticated.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        // Claims another device's id.205        assertFalse(206            identityMatchesAuthenticatedPeer(207                paired,208                honest.copy(deviceId = "different-device"),209                storedPublic,210            ),211        )212        // Advertises a key we never paired with.213        assertFalse(214            identityMatchesAuthenticatedPeer(215                paired,216                honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),217                spoofedPublic,218            ),219        )220        // Claims the paired key while Noise authenticated a different one — the221        // spoof this anchoring exists to stop.222        assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))223    }224}