1package com.peachpasswords.sync23import kotlinx.coroutines.CoroutineScope4import kotlinx.coroutines.Dispatchers5import kotlinx.coroutines.SupervisorJob6import kotlinx.coroutines.launch7import kotlinx.coroutines.runBlocking8import okhttp3.OkHttpClient9import okhttp3.Request10import okhttp3.Response11import okhttp3.WebSocket12import okhttp3.WebSocketListener13import okio.ByteString.Companion.toByteString14import org.json.JSONObject15import org.junit.Assert.assertEquals16import org.junit.Assert.assertTrue17import org.junit.Test18import java.util.concurrent.CompletableFuture19import java.util.concurrent.TimeUnit202122232425262728class CodePairingServerTest {2930 private val identity = LocalChirpIdentity(31 deviceId = "test-device",32 deviceName = "Test Phone",33 deviceType = DeviceType.ANDROID,34 longTermPublicKeyHex = "00".repeat(32),35 signingPublicKeyHex = "00".repeat(32),36 )3738 39 private class FakeRegistry : PairedDeviceRegistry {40 val devices = mutableListOf<PairedDevice>()41 override fun getAll(): List<PairedDevice> = devices42 override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id }43 override fun upsert(device: PairedDevice) { devices.add(device) }44 }4546 private fun server() = CodePairingServer(FakeRegistry())4748 private class Client {49 val opened = CompletableFuture<Boolean>()50 val closed = CompletableFuture<Boolean>()51 val frames = java.util.concurrent.LinkedBlockingQueue<String>()52 val binaryFrames = java.util.concurrent.LinkedBlockingQueue<ByteArray>()53 lateinit var ws: WebSocket5455 fun connect(port: Int) {56 ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket(57 Request.Builder().url("ws://127.0.0.1:$port").build(),58 object : WebSocketListener() {59 override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }60 override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }61 override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { binaryFrames.add(bytes.toByteArray()) }62 override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }63 override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }64 },65 )66 opened.get(5, TimeUnit.SECONDS)67 }68 }6970 7172737475 private class Harness(val scope: CoroutineScope) {76 val events = java.util.concurrent.LinkedBlockingQueue<PairingEvent>()77 inline fun <reified T : PairingEvent> await(timeoutMs: Long = 10_000): T {78 val deadline = System.currentTimeMillis() + timeoutMs79 val seen = mutableListOf<PairingEvent>()80 while (System.currentTimeMillis() < deadline) {81 val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue82 seen.add(e)83 if (e is T) return e84 }85 throw AssertionError("never saw ${T::class.simpleName}; saw $seen")86 }87 }8889 private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness {90 val h = Harness(this)91 launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } }92 return h93 }9495 @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking {96 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)97 val srv = server()98 try {99 val listening = scope.run(srv).await<PairingEvent.Listening>()100101 val c = Client()102 c.connect(listening.port)103 c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""")104105 assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))106 assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)107 } finally {108 srv.stop()109 }110 }111112 113114115116117118119120 @Test fun `a binary hello is accepted and answered with a binary ack`() = runBlocking {121 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)122 val srv = server()123 try {124 val listening = scope.run(srv).await<PairingEvent.Listening>()125 val token = PairingCode.parse(listening.code)!!.tokenHex126127 val c = Client()128 c.connect(listening.port)129 c.ws.send("""{"type":"PEACH_HELLO","token":"$token"}""".toByteArray().toByteString())130131 val ack = c.binaryFrames.poll(10, TimeUnit.SECONDS)132 assertTrue("the phone must answer a binary hello, not close on it", ack != null)133 assertTrue(134 "the ack must be a BINARY frame — a text one decodes to zero bytes in the browser",135 c.frames.isEmpty(),136 )137 assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK"))138 } finally {139 srv.stop()140 }141 }142143 @Test fun `LAN adoption dispatches before device-pair auth and keeps both SAS gates`() = runBlocking {144 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)145 val registry = FakeRegistry()146 val srv = CodePairingServer(registry)147 val root = ByteArray(32) { (it + 1).toByte() }148 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")149 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")150 val sourceIdentity = identity.copy(151 deviceId = "source-id",152 deviceName = "Source phone",153 longTermPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),154 relayUrl = "wss://relay.example.test",155 )156 var rootRequested = false157 val sourceDeps = object : AdoptionSourceDeps {158 override val sourceIdentity = sourceIdentity159 override suspend fun rootKeyHex(): String {160 rootRequested = true161 return Rendezvous.bytesToHex(root)162 }163 override suspend fun vaultId() = computeVaultId(root)164 override suspend fun onConsent(request: AdoptionFrame.Request) = true165 override suspend fun upsertPairedDevice(device: PairedDevice) {166 registry.upsert(device)167 }168 }169 val h = Harness(scope)170 srv.confirmationSink = { it.session.confirm(true) }171 scope.launch {172 srv.start(173 this,174 sourceKeys.secretKey,175 sourceKeys.publicKey,176 sourceIdentity,177 adoptionSourceDeps = sourceDeps,178 ).collect { h.events.add(it) }179 }180181 try {182 val listening = h.await<PairingEvent.Listening>()183 val token = PairingCode.parse(listening.code)!!.tokenHex184 val client = Client()185 client.connect(listening.port)186 client.ws.send(187 JSONObject()188 .put("type", "PEACH_HELLO")189 .put("token", token)190 .put("flow", "adoption")191 .toString()192 .toByteArray()193 .toByteString(),194 )195 val ack = JSONObject(String(client.binaryFrames.poll(10, TimeUnit.SECONDS)))196 val noise = noiseNKInitiator(197 hexToPubkey(ack.getString("ephemeralPublicKeyHex")),198 { client.ws.send(it.toByteString()) },199 {200 client.binaryFrames.poll(10, TimeUnit.SECONDS)201 ?: throw IllegalStateException("timed out waiting for server frame")202 },203 )204 val channel = object : PairingTransport {205 override suspend fun send(msg: ByteArray) {206 client.ws.send(noise.encrypt(msg).toByteString())207 }208 override suspend fun receive(): ByteArray {209 val frame = client.binaryFrames.poll(10, TimeUnit.SECONDS)210 ?: throw IllegalStateException("timed out waiting for encrypted frame")211 return noise.decrypt(frame)212 }213 override suspend fun close() {214 client.ws.close(1000, "done")215 }216 }217 val receiver = requestAdoption(218 channel,219 object : AdoptionReceiverDeps {220 override val deviceName = "Browser"221 override val deviceType = DeviceType.EXTENSION222 override fun generateDeviceId() = "session-request"223 override suspend fun confirmSas() = true224 override suspend fun createVaultFromRootKey(rootKeyHex: String) =225 computeVaultId(hexToPubkey(rootKeyHex))226 override suspend fun deriveLocalIdentity() = LocalChirpIdentity(227 deviceId = "receiver-id",228 deviceName = "Browser",229 deviceType = DeviceType.EXTENSION,230 longTermPublicKeyHex = Rendezvous.bytesToHex(receiverKeys.publicKey),231 signingPublicKeyHex = "ef".repeat(32),232 )233 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit234 override fun onError(error: String) = throw AssertionError(error)235 },236 )237238 assertTrue(rootRequested)239 assertEquals("source-id", receiver?.deviceId)240 assertEquals("Browser", h.await<PairingEvent.Paired>().deviceName)241 assertEquals("receiver-id", registry.devices.single().deviceId)242 assertEquals(64, registry.devices.single().rendezvousSecretHex?.length)243 assertEquals("wss://relay.example.test", registry.devices.single().relayUrl)244 } finally {245 srv.stop()246 }247 }248249 @Test fun `the pairing window expires and stops accepting`() = runBlocking {250 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)251 val srv = server()252 try {253 254 255 val h = scope.run(srv, windowMs = 300)256 val listening = h.await<PairingEvent.Listening>()257 h.await<PairingEvent.Expired>(timeoutMs = 5_000)258259 var refused = false260 runCatching { java.net.Socket("127.0.0.1", listening.port).close() }261 .onFailure { refused = true }262 assertTrue("the port should be closed after the window", refused)263 } finally {264 srv.stop()265 }266 }267268 @Test fun `the advertised address is never a tunnel interface`() {269 270 271 val ip = server().localIpv4()272 if (ip != null) {273 assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress)274 }275 }276}