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.Singleton2021222324252627282930313233343536373839404142434445sealed interface PairingEvent {46 47 data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent4849 50 data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent5152 data class Paired(val deviceName: String) : PairingEvent5354 55 data object Expired : PairingEvent5657 58 data class Rejected(val reason: String) : PairingEvent59}6061class PairingSession internal constructor() {62 private val decision = CompletableDeferred<Boolean>()63 fun confirm(matches: Boolean) { decision.complete(matches) }64 internal suspend fun await(): Boolean = decision.await()65}6667@Singleton68class CodePairingServer @Inject constructor(69 70 71 72 private val deviceStore: PairedDeviceRegistry,73) {74 private var serverSocket: ServerSocket? = null7576 companion object {77 78 const val PAIRING_WINDOW_MS = 5L * 60 * 10007980 81 private val TUNNEL_PREFIXES = listOf("tun", "ppp", "ipsec", "rmnet", "rndis", "ap")82 }8384 858687888990 fun start(91 scope: CoroutineScope,92 ourStaticPrivateKey: ByteArray,93 ourStaticPublicKey: ByteArray,94 localIdentity: LocalChirpIdentity,95 windowMs: Long = PAIRING_WINDOW_MS,96 ): Flow<PairingEvent> = callbackFlow {97 val ip = localIpv4()98 if (ip == null) {99 trySend(PairingEvent.Rejected("no_local_network"))100 close()101 return@callbackFlow102 }103104 val socket = withContext(Dispatchers.IO) { ServerSocket(0) }105 serverSocket = socket106 val token = PairingCode.newToken()107 val tokenHex = token.joinToString("") { "%02x".format(it) }108109 trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))110111 112 113 114 val expiryJob = scope.launch {115 delay(windowMs)116 trySend(PairingEvent.Expired)117 stop()118 }119120 val acceptJob = scope.launch(Dispatchers.IO) {121 while (isActive && !socket.isClosed) {122 val client = runCatching { socket.accept() }.getOrNull() ?: break123 launch {124 val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)125 trySend(event)126 127 128 129 if (event is PairingEvent.Paired) {130 expiryJob.cancel()131 stop()132 }133 }134 }135 }136137 awaitClose {138 expiryJob.cancel()139 acceptJob.cancel()140 stop()141 }142 }143144 fun stop() {145 runCatching { serverSocket?.close() }146 serverSocket = null147 }148149 private suspend fun handleClient(150 client: Socket,151 expectedTokenHex: String,152 ourStaticPrivateKey: ByteArray,153 ourStaticPublicKey: ByteArray,154 localIdentity: LocalChirpIdentity,155 ): PairingEvent = withContext(Dispatchers.IO) {156 val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())157 try {158 if (!ws.accept()) {159 ws.closeSilently()160 return@withContext PairingEvent.Rejected("not_a_websocket")161 }162163 val hello = ws.receive() as? WsMessage.Text164 ?: return@withContext rejectSilently(ws, "no_hello")165 val helloJson = runCatching { JSONObject(hello.text) }.getOrNull()166 ?: return@withContext rejectSilently(ws, "bad_hello")167 if (helloJson.optString("type") != "PEACH_HELLO") {168 return@withContext rejectSilently(ws, "bad_hello")169 }170 if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {171 return@withContext rejectSilently(ws, "bad_token")172 }173174 ws.sendText(175 JSONObject()176 .put("type", "PEACH_HELLO_ACK")177 .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })178 .toString(),179 )180181 val session = noiseNKResponder(182 ourStaticPrivateKey,183 ourStaticPublicKey,184 { frame -> ws.send(frame) },185 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },186 )187188 189 val theirIdentity = parsePairIdentityFrame(190 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),191 )192 ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))193194 val sas = SASDerivation.derive(session.handshakeHash)195 val device = pairIdentityToPairedDevice(theirIdentity)196 val pending = PairingSession()197 val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"198199 200 201 val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)202 confirmationSink?.invoke(confirmation)203 if (!pending.await()) {204 ws.closeSilently()205 return@withContext PairingEvent.Rejected("sas_mismatch")206 }207208 deviceStore.upsert(device)209 PairingEvent.Paired(name)210 } catch (e: Exception) {211 ws.closeSilently()212 PairingEvent.Rejected(e.message ?: "pairing_failed")213 } finally {214 runCatching { client.close() }215 }216 }217218 219220221222 var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null223224 private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {225 ws.closeSilently()226 return PairingEvent.Rejected(reason)227 }228229 230 private fun constantTimeEquals(a: String, b: String): Boolean {231 if (a.length != b.length) return false232 var diff = 0233 for (i in a.indices) diff = diff or (a[i].code xor b[i].code)234 return diff == 0235 }236237 238239240241242243244245246247 internal fun localIpv4(): String? = runCatching {248 val candidates = NetworkInterface.getNetworkInterfaces().toList()249 .filter { it.isUp && !it.isLoopback && !it.isVirtual }250 .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }251252 fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()253 .filterIsInstance<Inet4Address>()254 .firstOrNull { it.isSiteLocalAddress }255 ?.hostAddress256257 candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)258 ?: candidates.firstNotNullOfOrNull(::addressOf)259 }.getOrNull()260}