C7TransportTest.kt

10.9 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: same identity permits metadata-only replacement without duplication. */62    @Test63    fun `upsert by deviceId preserves identity while updating metadata`() {64        fun upsert(list: List<PairedDevice>, incoming: PairedDevice): List<PairedDevice> {65            val existing = list.firstOrNull { it.deviceId == incoming.deviceId }66            val bound = bindImmutableIdentity(existing, incoming)67            return list.filterNot { it.deviceId == incoming.deviceId } + bound68        }6970        val original = device("a", "Alice").copy(signingPublicKeyHex = "cd".repeat(32))71        var list = listOf(original)72        list = upsert(list, device("b", "Bob"))73        assertEquals(2, list.size)74        list = upsert(list, device("a", "Alice-renamed").copy(signingPublicKeyHex = null))75        assertEquals(2, list.size)76        assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName)77        assertEquals(original.longTermPublicKeyHex, list.first { it.deviceId == "a" }.longTermPublicKeyHex)78        assertEquals(original.signingPublicKeyHex, list.first { it.deviceId == "a" }.signingPublicKeyHex)79    }8081    @Test82    fun `identity key changes are rejected before list replacement`() {83        val original = device("a").copy(signingPublicKeyHex = "cd".repeat(32))84        val list = listOf(original)85        val changedLongTerm = original.copy(longTermPublicKeyHex = "ef".repeat(32), deviceName = "spoofed")86        val changedSigning = original.copy(signingPublicKeyHex = "12".repeat(32), deviceName = "spoofed")8788        assertEquals(89            "device_identity_immutable",90            assertThrows(IllegalArgumentException::class.java) {91                bindImmutableIdentity(list.first { it.deviceId == changedLongTerm.deviceId }, changedLongTerm)92            }.message,93        )94        assertEquals(95            "device_identity_immutable",96            assertThrows(IllegalArgumentException::class.java) {97                bindImmutableIdentity(list.first { it.deviceId == changedSigning.deviceId }, changedSigning)98            }.message,99        )100        assertEquals(listOf(original), list)101    }102103    /** Test 3: writeFrame/readFrame round-trip across payload sizes. */104    @Test105    fun `frame codec round-trips for various sizes`() {106        for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) {107            val payload = ByteArray(size) { (it % 251).toByte() }108            val pin = PipedInputStream(1 shl 18)109            val pout = PipedOutputStream(pin)110            writeFrame(pout, payload)111            val read = readFrame(pin)112            assertArrayEquals("size=$size", payload, read)113        }114    }115116    /**117     * Test 4: the first-pair Noise NK primitive + identity frame exchange over118     * piped frames. Live reconnect uses KK; this pins the separate first-pair119     * pattern that remains valid when the initiator is not known yet.120     */121    @Test122    fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {123        val (responderPriv, responderPub) = newKeyPair()124125        // Cross-wired pipes: initiator's out -> responder's in and vice versa.126        val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)127        val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)128129        val initiator = SocketFrameIO(input = r2i, output = i2rOut)130        val responder = SocketFrameIO(input = i2r, output = r2iOut)131132        val localId = LocalDeviceIdentity(133            deviceId = "client-1",134            deviceName = "Client",135            deviceType = DeviceType.ANDROID,136            longTermPublicKeyHex = "cd".repeat(32),137            signingPublicKeyHex = "ef".repeat(32),138        )139        val serverId = localId.copy(deviceId = "server-1", deviceName = "Server")140141        var initiatorSas: String? = null142        var responderSas: String? = null143        var receivedByServer: PairIdentity? = null144145        val responderJob = launch(Dispatchers.IO) {146            val session = noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive)147            receivedByServer = parsePairIdentityFrame(session.decrypt(responder.receive()))148            responder.send(session.encrypt(encodePairIdentityFrame(serverId)))149            responderSas = SASDerivation.derive(session.handshakeHash)150        }151152        val initiatorJob = launch(Dispatchers.IO) {153            val session = noiseNKInitiator(responderPub, initiator::send, initiator::receive)154            initiator.send(session.encrypt(encodePairIdentityFrame(localId)))155            session.decrypt(initiator.receive()) // server identity (discarded here)156            initiatorSas = SASDerivation.derive(session.handshakeHash)157        }158159        initiatorJob.join()160        responderJob.join()161162        assertNotNull(initiatorSas)163        assertEquals("both sides derive the same SAS", initiatorSas, responderSas)164        assertEquals("client-1", receivedByServer?.deviceId)165        assertEquals("cd".repeat(32), receivedByServer?.longTermPublicKeyHex)166    }167168    /**169     * Test 5: a wrong responder static key fails the first-pair NK primitive.170     */171    @Test172    fun `handshake fails when responder static key is wrong`() = runBlocking {173        val (responderPriv, responderPub) = newKeyPair()174        val (_, wrongPub) = newKeyPair()175176        val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)177        val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)178        val initiator = SocketFrameIO(input = r2i, output = i2rOut)179        val responder = SocketFrameIO(input = i2r, output = r2iOut)180181        // The initiator pins the WRONG static key, so the responder rejects msg1 (its182        // own ES differs) and never sends msg2. Close the responder's write end on183        // failure so the initiator's receive() hits EOF and fails fast instead of184        // blocking forever — the raw-socket analogue is the soTimeout connectAndSync185        // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking186        // pipe read, so EOF is the deterministic way to unblock it.)187        val responderJob = launch(Dispatchers.IO) {188            runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) }189            runCatching { r2iOut.close() }190        }191192        var initiatorFailed = false193        val initiatorJob = launch(Dispatchers.IO) {194            try {195                noiseNKInitiator(wrongPub, initiator::send, initiator::receive)196            } catch (_: Throwable) {197                initiatorFailed = true198            }199        }200201        initiatorJob.join()202        responderJob.join()203        assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)204    }205206    @Test207    fun `a presented identity cannot replace the locally paired key`() {208        // Was `reconnect discovery cannot replace the locally paired responder209        // key`, which pinned a LAN-discovered endpoint. Discovery is gone with210        // LAN sync (docs/sync-lan-retirement.md), but the property it protected211        // is not: a peer's self-asserted PAIR_IDENTITY must never override the212        // paired record, and is only believed when it matches the static key213        // Noise actually authenticated.214        val (_, storedPublic) = newKeyPair()215        val (_, spoofedPublic) = newKeyPair()216        val paired = device("phone").copy(217            longTermPublicKeyHex = pubkeyToHex(storedPublic),218        )219220        val honest = PairIdentity(221            deviceId = paired.deviceId,222            deviceName = "Phone",223            deviceType = DeviceType.ANDROID,224            longTermPublicKeyHex = pubkeyToHex(storedPublic),225            signingPublicKeyHex = null,226            wsPort = null,227            relayUrl = null,228        )229        assertTrue(identityMatchesAuthenticatedPeer(paired, honest, storedPublic))230231        // Claims another device's id.232        assertFalse(233            identityMatchesAuthenticatedPeer(234                paired,235                honest.copy(deviceId = "different-device"),236                storedPublic,237            ),238        )239        // Advertises a key we never paired with.240        assertFalse(241            identityMatchesAuthenticatedPeer(242                paired,243                honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),244                spoofedPublic,245            ),246        )247        // Claims the paired key while Noise authenticated a different one — the248        // spoof this anchoring exists to stop.249        assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))250    }251}