ChirpPairingTest.kt

9.6 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertTrue7import org.junit.Test89private fun fakeCodecFactory(): ChirpCodecFactory = { _ ->10    object : ChirpCodec {11        override fun encode(payload: ByteArray): FloatArray {12        val out = FloatArray(maxOf(payload.size, 128))13        for (i in payload.indices) {14            out[i] = (payload[i].toInt() and 0xff) / 255f15        }16            for (i in payload.size until out.size) {17                out[i] = 99f18            }19            return out20        }2122        override fun decode(samples: FloatArray): ByteArray? {23            if (samples.size < CHIRP_PAYLOAD_LENGTH) return null24            val buf = ByteArray(CHIRP_PAYLOAD_LENGTH)25            for (i in 0 until CHIRP_PAYLOAD_LENGTH) {26                val v = samples[i]27                if (v == 99f) return null28                buf[i] = (v * 255f).toInt().toByte()29            }30            return buf31        }3233        override fun free() = Unit34    }35}3637private fun fakeAudio(injected: FloatArray? = null): Pair<AudioIO, (FloatArray) -> Unit> {38    var pending = injected ?: FloatArray(0)39    var playCount = 040    var closed = false41    val io = object : AudioIO {42        override val sampleRate: Int = 48_00043        override suspend fun play(waveform: FloatArray) {44            playCount++45        }4647        override suspend fun pullSamples(): FloatArray {48            val out = pending49            pending = FloatArray(0)50            return out51        }5253        override suspend fun close() {54            closed = true55        }56    }57    return io to { samples ->58        val next = FloatArray(pending.size + samples.size)59        pending.copyInto(next, 0)60        samples.copyInto(next, pending.size)61        pending = next62    }63}6465private fun chirpWave(payload: ChirpPayload): FloatArray {66    val bytes = encodeChirpPayload(payload)67    val wave = FloatArray(128)68    for (i in bytes.indices) {69        wave[i] = (bytes[i].toInt() and 0xff) / 255f70    }71    for (i in bytes.size until wave.size) {72        wave[i] = 99f73    }74    return wave75}7677class ChirpPairingTest {78    @Test79    fun `createPairingAttempt rolls fresh ephemerals and nonce`() {80        val a = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))81        val b = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))82        assertFalse(a.ephemeralPublicKey.contentEquals(b.ephemeralPublicKey))83        assertEquals(16, a.sessionNonce.size)84        assertTrue(a.outgoingPayload.ephemeralPublicKey.contentEquals(a.ephemeralPublicKey))85        assertEquals(deviceTypeToByte(DeviceType.EXTENSION), a.outgoingPayload.deviceType)86    }8788    @Test89    fun `runChirpExchange returns remote payload when received`() = runBlocking {90        val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID))91        val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))92        val (io, inject) = fakeAudio()93        inject(chirpWave(theirs.outgoingPayload))9495        val result = runChirpExchange(96            io = io,97            opts = ChirpExchangeOptions(98                attempt = ours,99                timeoutMs = 2_000,100            ),101            codecFactory = fakeCodecFactory(),102        )103104        assertTrue(result.remoteChirp.ephemeralPublicKey.contentEquals(theirs.ephemeralPublicKey))105        assertTrue(result.remoteChirp.sessionNonce.contentEquals(theirs.sessionNonce))106        assertEquals(deviceTypeToByte(DeviceType.ANDROID), result.remoteChirp.deviceType)107    }108109    @Test110    fun `runChirpExchange ignores own chirp and keeps listening`() = runBlocking {111        val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))112        val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID))113        val (io, inject) = fakeAudio(chirpWave(ours.outgoingPayload))114        val peerWave = chirpWave(theirs.outgoingPayload)115        kotlin.concurrent.thread {116            Thread.sleep(150)117            inject(peerWave)118        }119120        val result = runChirpExchange(121            io = io,122            opts = ChirpExchangeOptions(123                attempt = ours,124                timeoutMs = 3_000,125            ),126            codecFactory = fakeCodecFactory(),127        )128129        assertTrue(result.remoteChirp.ephemeralPublicKey.contentEquals(theirs.ephemeralPublicKey))130    }131132    @Test133    fun `runChirpExchange times out when no chirp arrives`() = runBlocking {134        val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))135        val (io, _) = fakeAudio()136        try {137            runChirpExchange(138                io = io,139                opts = ChirpExchangeOptions(140                    attempt = ours,141                    timeoutMs = 200,142                ),143                codecFactory = fakeCodecFactory(),144            )145        } catch (err: ChirpExchangeError) {146            assertEquals(ChirpExchangeErrorKind.TIMEOUT, err.kind)147            return@runBlocking148        }149        throw AssertionError("expected timeout")150    }151152    @Test153    fun `runChirpExchange raises version mismatch on future protocol`() = runBlocking {154        val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))155        val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID))156        val tweaked = theirs.outgoingPayload.copy(version = 0x02)157        val (io, inject) = fakeAudio()158        inject(chirpWave(tweaked))159160        try {161            runChirpExchange(162                io = io,163                opts = ChirpExchangeOptions(164                    attempt = ours,165                    timeoutMs = 2_000,166                ),167                codecFactory = fakeCodecFactory(),168            )169        } catch (err: ChirpExchangeError) {170            assertEquals(ChirpExchangeErrorKind.VERSION_MISMATCH, err.kind)171            return@runBlocking172        }173        throw AssertionError("expected version mismatch")174    }175176    @Test177    fun `pair frame round-trips an optional Pro license`() {178        val frame = PairingFrame(179            type = "PAIR_IDENTITY",180            deviceId = "a",181            deviceName = "Pixel",182            deviceType = DeviceType.ANDROID,183            longTermPublicKeyHex = "0".repeat(64),184            signingPublicKeyHex = "1".repeat(64),185            license = "PPK-AAA-BBB",186        )187        val json = encodePairingFrame(frame).decodeToString()188        assertTrue(json.contains("\"license\":\"PPK-AAA-BBB\""))189        assertEquals("PPK-AAA-BBB", decodePairingFrame(json.encodeToByteArray()).license)190191        // Omitted entirely when null (forward-compatible with peers that ignore it).192        val noLicense = encodePairingFrame(frame.copy(license = null)).decodeToString()193        assertFalse(noLicense.contains("license"))194    }195196    @Test197    fun `decodes a browser PAIR_IDENTITY frame carrying a license`() {198        val browserJson = "{\"type\":\"PAIR_IDENTITY\"," +199            "\"device_id\":\"ext-1\",\"device_name\":\"Firefox\"," +200            "\"device_type\":\"extension\"," +201            "\"long_term_public_key_hex\":\"${"0".repeat(64)}\"," +202            "\"license\":\"PPK-XYZ-123\"}"203        assertEquals("PPK-XYZ-123", decodePairingFrame(browserJson.encodeToByteArray()).license)204    }205206    @Test207    fun `runChirpPairing emits listening before timeout`() = runBlocking {208        val trail = mutableListOf<PairingProgress>()209        try {210            runChirpPairing(211                deps = object : ChirpPairingDeps {212                    override suspend fun fetchLocalIdentity(): LocalChirpIdentity {213                        return LocalChirpIdentity(214                            deviceId = "ext",215                            deviceName = "Firefox",216                            deviceType = DeviceType.EXTENSION,217                            longTermPublicKeyHex = "0".repeat(64),218                            signingPublicKeyHex = "1".repeat(64),219                        )220                    }221222                    override suspend fun completePairing(paired: PairedDevice) = Unit223                    override suspend fun confirmSAS(sas: String): Boolean = true224                    override suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String) = Unit225                    override suspend fun getLocalSubnet(): String? = "192.168.1"226                    override suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport {227                        throw IllegalStateException("unreached")228                    }229                    override suspend fun openPairingServer(): PairingServer {230                        throw IllegalStateException("unreached")231                    }232                    override suspend fun openAudio(): AudioIO {233                        return object : AudioIO {234                            override val sampleRate: Int = 48_000235                            override suspend fun play(waveform: FloatArray) = Unit236                            override suspend fun pullSamples(): FloatArray = FloatArray(0)237                            override suspend fun close() = Unit238                        }239                    }240241                    override val chirpTimeoutMs: Long = 200242                    override fun onProgress(progress: PairingProgress) {243                        trail.add(progress)244                    }245                },246                role = PairingRole.RESPONDER,247                codecFactory = fakeCodecFactory(),248            )249        } catch (_: ChirpPairingFailure) {250            // expected timeout251        }252253        assertEquals(PairingPhase.IDLE, trail.first().phase)254        assertEquals(PairingPhase.LISTENING, trail[1].phase)255    }256}