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.SecureRandom192021222324252627282930class 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 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 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 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 117118119120121 @Test122 fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {123 val (responderPriv, responderPub) = newKeyPair()124125 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()) 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 169170171 @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 182 183 184 185 186 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 209 210 211 212 213 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 232 assertFalse(233 identityMatchesAuthenticatedPeer(234 paired,235 honest.copy(deviceId = "different-device"),236 storedPublic,237 ),238 )239 240 assertFalse(241 identityMatchesAuthenticatedPeer(242 paired,243 honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),244 spoofedPublic,245 ),246 )247 248 249 assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))250 }251}