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 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 251 }252253 assertEquals(PairingPhase.IDLE, trail.first().phase)254 assertEquals(PairingPhase.LISTENING, trail[1].phase)255 }256}