1package com.peachpasswords.sync23import kotlinx.coroutines.CompletableDeferred4import kotlinx.coroutines.CoroutineScope5import kotlinx.coroutines.Dispatchers6import kotlinx.coroutines.channels.awaitClose7import kotlinx.coroutines.delay8import kotlinx.coroutines.flow.Flow9import kotlinx.coroutines.flow.callbackFlow10import kotlinx.coroutines.isActive11import kotlinx.coroutines.launch12import kotlinx.coroutines.withContext13import org.json.JSONObject14import java.net.Inet4Address15import java.net.NetworkInterface16import java.net.ServerSocket17import java.net.Socket18import javax.inject.Inject19import javax.inject.Singleton20212223242526272829303132333435363738394041424344454647484950sealed interface PairingEvent {51 52 data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent5354 55 data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent5657 data class Paired(val deviceName: String) : PairingEvent5859 60 data object Expired : PairingEvent6162 63 data class Rejected(val reason: String) : PairingEvent64}6566class PairingSession internal constructor() {67 private val decision = CompletableDeferred<Boolean>()68 fun confirm(matches: Boolean) { decision.complete(matches) }69 internal suspend fun await(): Boolean = decision.await()70}7172@Singleton73class CodePairingServer @Inject constructor(74 75 76 77 private val deviceStore: PairedDeviceRegistry,78) {79 private var serverSocket: ServerSocket? = null8081 companion object {82 83 const val PAIRING_WINDOW_MS = 5L * 60 * 10008485 86 private val TUNNEL_PREFIXES = listOf("tun", "ppp", "ipsec", "rmnet", "rndis", "ap")87 }8889 909192939495 fun start(96 scope: CoroutineScope,97 ourStaticPrivateKey: ByteArray,98 ourStaticPublicKey: ByteArray,99 localIdentity: LocalChirpIdentity,100 windowMs: Long = PAIRING_WINDOW_MS,101 ): Flow<PairingEvent> = callbackFlow {102 val ip = localIpv4()103 if (ip == null) {104 trySend(PairingEvent.Rejected("no_local_network"))105 close()106 return@callbackFlow107 }108109 val socket = withContext(Dispatchers.IO) { ServerSocket(0) }110 serverSocket = socket111 val token = PairingCode.newToken()112 val tokenHex = token.joinToString("") { "%02x".format(it) }113114 trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))115116 117 118 119 val expiryJob = scope.launch {120 delay(windowMs)121 trySend(PairingEvent.Expired)122 stop()123 }124125 val acceptJob = scope.launch(Dispatchers.IO) {126 while (isActive && !socket.isClosed) {127 val client = runCatching { socket.accept() }.getOrNull() ?: break128 launch {129 val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)130 trySend(event)131 132 133 134 if (event is PairingEvent.Paired) {135 expiryJob.cancel()136 stop()137 }138 }139 }140 }141142 awaitClose {143 expiryJob.cancel()144 acceptJob.cancel()145 stop()146 }147 }148149 fun stop() {150 runCatching { serverSocket?.close() }151 serverSocket = null152 }153154 private suspend fun handleClient(155 client: Socket,156 expectedTokenHex: String,157 ourStaticPrivateKey: ByteArray,158 ourStaticPublicKey: ByteArray,159 localIdentity: LocalChirpIdentity,160 ): PairingEvent = withContext(Dispatchers.IO) {161 val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())162 try {163 if (!ws.accept()) {164 ws.closeSilently()165 return@withContext PairingEvent.Rejected("not_a_websocket")166 }167168 169 170 171 172 173 174 175 val helloText = when (val frame = ws.receive()) {176 is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)177 is WsMessage.Text -> frame.text178 else -> return@withContext rejectSilently(ws, "no_hello")179 }180 val helloJson = runCatching { JSONObject(helloText) }.getOrNull()181 ?: return@withContext rejectSilently(ws, "bad_hello")182 if (helloJson.optString("type") != "PEACH_HELLO") {183 return@withContext rejectSilently(ws, "bad_hello")184 }185 if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {186 return@withContext rejectSilently(ws, "bad_token")187 }188189 190 191 192 193 ws.send(194 JSONObject()195 .put("type", "PEACH_HELLO_ACK")196 .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })197 .toString()198 .toByteArray(Charsets.UTF_8),199 )200201 val session = noiseNKResponder(202 ourStaticPrivateKey,203 ourStaticPublicKey,204 { frame -> ws.send(frame) },205 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },206 )207208 209 val theirIdentity = parsePairIdentityFrame(210 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),211 )212 ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))213214 val sas = SASDerivation.derive(session.handshakeHash)215 val device = Rendezvous.bindPairState(216 device = pairIdentityToPairedDevice(theirIdentity),217 handshakeHash = session.handshakeHash,218 219 relayUrl = localIdentity.relayUrl,220 )221 val pending = PairingSession()222 val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"223224 225 226 val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)227 confirmationSink?.invoke(confirmation)228 if (!pending.await()) {229 ws.closeSilently()230 return@withContext PairingEvent.Rejected("sas_mismatch")231 }232233 deviceStore.upsert(device)234 PairingEvent.Paired(name)235 } catch (e: Exception) {236 ws.closeSilently()237 PairingEvent.Rejected(e.message ?: "pairing_failed")238 } finally {239 runCatching { client.close() }240 }241 }242243 244245246247 var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null248249 private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {250 ws.closeSilently()251 return PairingEvent.Rejected(reason)252 }253254 255 private fun constantTimeEquals(a: String, b: String): Boolean {256 if (a.length != b.length) return false257 var diff = 0258 for (i in a.indices) diff = diff or (a[i].code xor b[i].code)259 return diff == 0260 }261262 263264265266267268269270271272 internal fun localIpv4(): String? = runCatching {273 val candidates = NetworkInterface.getNetworkInterfaces().toList()274 .filter { it.isUp && !it.isLoopback && !it.isVirtual }275 .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }276277 fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()278 .filterIsInstance<Inet4Address>()279 .firstOrNull { it.isSiteLocalAddress }280 ?.hostAddress281282 candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)283 ?: candidates.firstNotNullOfOrNull(::addressOf)284 }.getOrNull()285}