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 210 211 val authenticatedInitiatorKey = performResponderPairingAuth(212 session = session,213 ourPrivateKey = ourStaticPrivateKey,214 ourPublicKey = ourStaticPublicKey,215 sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },216 receiveEncryptedJson = {217 session.decrypt(218 (ws.receive() as? WsMessage.Binary)?.bytes219 ?: throw IllegalStateException("peer closed"),220 )221 },222 )223224 225 val theirIdentity = parsePairIdentityFrame(226 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),227 )228 require(229 java.security.MessageDigest.isEqual(230 authenticatedInitiatorKey,231 hexToPubkey(theirIdentity.longTermPublicKeyHex),232 ),233 ) { "PAIR_IDENTITY key does not match proven initiator key" }234 ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))235236 val sas = SASDerivation.derive(session.handshakeHash)237 val device = Rendezvous.bindPairState(238 device = pairIdentityToPairedDevice(theirIdentity),239 handshakeHash = session.handshakeHash,240 241 relayUrl = localIdentity.relayUrl,242 )243 val pending = PairingSession()244 val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"245246 247 248 val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)249 confirmationSink?.invoke(confirmation)250 if (!pending.await()) {251 ws.closeSilently()252 return@withContext PairingEvent.Rejected("sas_mismatch")253 }254255 deviceStore.upsert(device)256 PairingEvent.Paired(name)257 } catch (e: Exception) {258 ws.closeSilently()259 PairingEvent.Rejected(e.message ?: "pairing_failed")260 } finally {261 runCatching { client.close() }262 }263 }264265 266267268269 var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null270271 private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {272 ws.closeSilently()273 return PairingEvent.Rejected(reason)274 }275276 277 private fun constantTimeEquals(a: String, b: String): Boolean {278 if (a.length != b.length) return false279 var diff = 0280 for (i in a.indices) diff = diff or (a[i].code xor b[i].code)281 return diff == 0282 }283284 285286287288289290291292293294 internal fun localIpv4(): String? = runCatching {295 val candidates = NetworkInterface.getNetworkInterfaces().toList()296 .filter { it.isUp && !it.isLoopback && !it.isVirtual }297 .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }298299 fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()300 .filterIsInstance<Inet4Address>()301 .firstOrNull { it.isSiteLocalAddress }302 ?.hostAddress303304 candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)305 ?: candidates.firstNotNullOfOrNull(::addressOf)306 }.getOrNull()307}