1package com.peachpasswords.sync23import kotlinx.coroutines.CompletableDeferred4import kotlinx.coroutines.CoroutineScope5import kotlinx.coroutines.Dispatchers6import kotlinx.coroutines.channels.awaitClose7import kotlinx.coroutines.flow.Flow8import kotlinx.coroutines.flow.callbackFlow9import kotlinx.coroutines.isActive10import kotlinx.coroutines.launch11import kotlinx.coroutines.withContext12import org.json.JSONObject13import java.net.Inet4Address14import java.net.NetworkInterface15import java.net.ServerSocket16import java.net.Socket17import javax.inject.Inject18import javax.inject.Singleton1920212223242526272829303132333435363738394041424344sealed interface PairingEvent {45 46 data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent4748 49 data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent5051 data class Paired(val deviceName: String) : PairingEvent5253 54 data class Rejected(val reason: String) : PairingEvent55}5657class PairingSession internal constructor() {58 private val decision = CompletableDeferred<Boolean>()59 fun confirm(matches: Boolean) { decision.complete(matches) }60 internal suspend fun await(): Boolean = decision.await()61}6263@Singleton64class CodePairingServer @Inject constructor(65 private val deviceStore: PairedDeviceStore,66) {67 private var serverSocket: ServerSocket? = null6869 707172737475 fun start(76 scope: CoroutineScope,77 ourStaticPrivateKey: ByteArray,78 ourStaticPublicKey: ByteArray,79 localIdentity: LocalChirpIdentity,80 ): Flow<PairingEvent> = callbackFlow {81 val ip = localIpv4()82 if (ip == null) {83 trySend(PairingEvent.Rejected("no_local_network"))84 close()85 return@callbackFlow86 }8788 val socket = withContext(Dispatchers.IO) { ServerSocket(0) }89 serverSocket = socket90 val token = PairingCode.newToken()91 val tokenHex = token.joinToString("") { "%02x".format(it) }9293 trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))9495 val acceptJob = scope.launch(Dispatchers.IO) {96 while (isActive && !socket.isClosed) {97 val client = runCatching { socket.accept() }.getOrNull() ?: break98 launch {99 trySend(100 handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity),101 )102 }103 }104 }105106 awaitClose {107 acceptJob.cancel()108 stop()109 }110 }111112 fun stop() {113 runCatching { serverSocket?.close() }114 serverSocket = null115 }116117 private suspend fun handleClient(118 client: Socket,119 expectedTokenHex: String,120 ourStaticPrivateKey: ByteArray,121 ourStaticPublicKey: ByteArray,122 localIdentity: LocalChirpIdentity,123 ): PairingEvent = withContext(Dispatchers.IO) {124 val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())125 try {126 if (!ws.accept()) {127 ws.closeSilently()128 return@withContext PairingEvent.Rejected("not_a_websocket")129 }130131 val hello = ws.receive() as? WsMessage.Text132 ?: return@withContext rejectSilently(ws, "no_hello")133 val helloJson = runCatching { JSONObject(hello.text) }.getOrNull()134 ?: return@withContext rejectSilently(ws, "bad_hello")135 if (helloJson.optString("type") != "PEACH_HELLO") {136 return@withContext rejectSilently(ws, "bad_hello")137 }138 if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {139 return@withContext rejectSilently(ws, "bad_token")140 }141142 ws.sendText(143 JSONObject()144 .put("type", "PEACH_HELLO_ACK")145 .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })146 .toString(),147 )148149 val session = noiseNKResponder(150 ourStaticPrivateKey,151 ourStaticPublicKey,152 { frame -> ws.send(frame) },153 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },154 )155156 157 val theirIdentity = parsePairIdentityFrame(158 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),159 )160 ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))161162 val sas = SASDerivation.derive(session.handshakeHash)163 val device = pairIdentityToPairedDevice(theirIdentity)164 val pending = PairingSession()165 val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"166167 168 169 val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)170 confirmationSink?.invoke(confirmation)171 if (!pending.await()) {172 ws.closeSilently()173 return@withContext PairingEvent.Rejected("sas_mismatch")174 }175176 deviceStore.upsert(device)177 PairingEvent.Paired(name)178 } catch (e: Exception) {179 ws.closeSilently()180 PairingEvent.Rejected(e.message ?: "pairing_failed")181 } finally {182 runCatching { client.close() }183 }184 }185186 187188189190 var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null191192 private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {193 ws.closeSilently()194 return PairingEvent.Rejected(reason)195 }196197 198 private fun constantTimeEquals(a: String, b: String): Boolean {199 if (a.length != b.length) return false200 var diff = 0201 for (i in a.indices) diff = diff or (a[i].code xor b[i].code)202 return diff == 0203 }204205 206207208209210 private fun localIpv4(): String? =211 runCatching {212 NetworkInterface.getNetworkInterfaces().toList()213 .filter { it.isUp && !it.isLoopback && !it.isVirtual }214 .flatMap { it.inetAddresses.toList() }215 .filterIsInstance<Inet4Address>()216 .firstOrNull { it.isSiteLocalAddress }217 ?.hostAddress218 }.getOrNull()219}