package com.peachpasswords.sync import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import org.bouncycastle.crypto.params.X25519PrivateKeyParameters import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import java.io.PipedInputStream import java.io.PipedOutputStream import java.security.SecureRandom /** * C7 transport tests. Mirrors the existing ChirpSyncTransportTest idiom: plain * JUnit + runBlocking, no Robolectric. PairedDeviceStore itself needs an Android * Context (EncryptedSharedPreferences), so its persistence is exercised by the * orchestrator's instrumentation tests; here we test the pure-JVM serialization * layer the store round-trips through (test 1) plus its upsert/replace semantics * against an in-memory list (test 2), the frame codec (test 3), the first-pair * Noise-NK primitive over frames (tests 4–5), and the local-state key pin used * by the live KK reconnect discovery path (test 6). */ class C7ChirpTransportTest { private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } private val listSerializer = ListSerializer(PairedDevice.serializer()) private fun newKeyPair(): Pair { val priv = X25519PrivateKeyParameters(SecureRandom()) return priv.encoded to priv.generatePublicKey().encoded } private fun device(id: String, name: String = "Peer") = PairedDevice( deviceId = id, deviceName = name, deviceType = "android", longTermPublicKeyHex = "ab".repeat(32), trustSource = "direct", pairedAt = 1_000L, lastSyncedAt = null, ) /** Test 1: PairedDevice list survives JSON round-trip (the store's wire form). */ @Test fun `paired device list round-trips through json`() { val devices = listOf(device("a", "Alice"), device("b", "Bob")) val encoded = json.encodeToString(listSerializer, devices) val decoded = json.decodeFromString(listSerializer, encoded) assertEquals(devices, decoded) assertEquals("Alice", decoded[0].deviceName) assertEquals("ab".repeat(32), decoded[1].longTermPublicKeyHex) } /** Test 2: upsert replaces an existing entry by deviceId (store semantics). */ @Test fun `upsert by deviceId replaces rather than duplicates`() { // Mirror PairedDeviceStore.upsert's pure list transform. fun upsert(list: List, d: PairedDevice) = list.filterNot { it.deviceId == d.deviceId } + d var list = listOf(device("a", "Alice")) list = upsert(list, device("b", "Bob")) assertEquals(2, list.size) list = upsert(list, device("a", "Alice-renamed")) assertEquals(2, list.size) assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName) } /** Test 3: writeFrame/readFrame round-trip across payload sizes. */ @Test fun `frame codec round-trips for various sizes`() { for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) { val payload = ByteArray(size) { (it % 251).toByte() } val pin = PipedInputStream(1 shl 18) val pout = PipedOutputStream(pin) writeFrame(pout, payload) val read = readFrame(pin) assertArrayEquals("size=$size", payload, read) } } /** * Test 4: the first-pair Noise NK primitive + identity frame exchange over * piped frames. Live reconnect uses KK; this pins the separate first-pair * pattern that remains valid when the initiator is not known yet. */ @Test fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking { val (responderPriv, responderPub) = newKeyPair() // Cross-wired pipes: initiator's out -> responder's in and vice versa. val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r) val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i) val initiator = SocketFrameIO(input = r2i, output = i2rOut) val responder = SocketFrameIO(input = i2r, output = r2iOut) val localId = LocalChirpIdentity( deviceId = "client-1", deviceName = "Client", deviceType = DeviceType.ANDROID, longTermPublicKeyHex = "cd".repeat(32), signingPublicKeyHex = "ef".repeat(32), ) val serverId = localId.copy(deviceId = "server-1", deviceName = "Server") var initiatorSas: String? = null var responderSas: String? = null var receivedByServer: PairIdentity? = null val responderJob = launch(Dispatchers.IO) { val session = noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) receivedByServer = parsePairIdentityFrame(session.decrypt(responder.receive())) responder.send(session.encrypt(encodePairIdentityFrame(serverId))) responderSas = SASDerivation.derive(session.handshakeHash) } val initiatorJob = launch(Dispatchers.IO) { val session = noiseNKInitiator(responderPub, initiator::send, initiator::receive) initiator.send(session.encrypt(encodePairIdentityFrame(localId))) session.decrypt(initiator.receive()) // server identity (discarded here) initiatorSas = SASDerivation.derive(session.handshakeHash) } initiatorJob.join() responderJob.join() assertNotNull(initiatorSas) assertEquals("both sides derive the same SAS", initiatorSas, responderSas) assertEquals("client-1", receivedByServer?.deviceId) assertEquals("cd".repeat(32), receivedByServer?.longTermPublicKeyHex) } /** * Test 5: a wrong responder static key fails the first-pair NK primitive. */ @Test fun `handshake fails when responder static key is wrong`() = runBlocking { val (responderPriv, responderPub) = newKeyPair() val (_, wrongPub) = newKeyPair() val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r) val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i) val initiator = SocketFrameIO(input = r2i, output = i2rOut) val responder = SocketFrameIO(input = i2r, output = r2iOut) // The initiator pins the WRONG static key, so the responder rejects msg1 (its // own ES differs) and never sends msg2. Close the responder's write end on // failure so the initiator's receive() hits EOF and fails fast instead of // blocking forever — the raw-socket analogue is the soTimeout connectAndSync // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking // pipe read, so EOF is the deterministic way to unblock it.) val responderJob = launch(Dispatchers.IO) { runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) } runCatching { r2iOut.close() } } var initiatorFailed = false val initiatorJob = launch(Dispatchers.IO) { try { noiseNKInitiator(wrongPub, initiator::send, initiator::receive) } catch (_: Throwable) { initiatorFailed = true } } initiatorJob.join() responderJob.join() assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed) } @Test fun `reconnect discovery cannot replace the locally paired responder key`() { val (_, storedPublic) = newKeyPair() val (_, advertisedPublic) = newKeyPair() val paired = device("phone").copy( longTermPublicKeyHex = pubkeyToHex(storedPublic), ) val advertised = DiscoveredPeer( deviceId = paired.deviceId, deviceName = "Spoofed endpoint", host = "192.0.2.1", port = 49142, remoteStaticPublicKey = advertisedPublic, ) val pinned = pinDiscoveredPeerToPairedState(paired, advertised) assertArrayEquals(storedPublic, pinned.remoteStaticPublicKey) assertThrows(IllegalArgumentException::class.java) { pinDiscoveredPeerToPairedState( paired, advertised.copy(deviceId = "different-device"), ) } } }