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 adoptionSourceDeps: AdoptionSourceDeps? = null,102 ): Flow<PairingEvent> = callbackFlow {103 val ip = localIpv4()104 if (ip == null) {105 trySend(PairingEvent.Rejected("no_local_network"))106 close()107 return@callbackFlow108 }109110 val socket = withContext(Dispatchers.IO) { ServerSocket(0) }111 serverSocket = socket112 val token = PairingCode.newToken()113 val tokenHex = token.joinToString("") { "%02x".format(it) }114115 trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))116117 118 119 120 val expiryJob = scope.launch {121 delay(windowMs)122 trySend(PairingEvent.Expired)123 stop()124 this@callbackFlow.close()125 }126127 val acceptJob = scope.launch(Dispatchers.IO) {128 while (isActive && !socket.isClosed) {129 val client = runCatching { socket.accept() }.getOrNull() ?: break130 launch {131 val event = handleClient(132 client,133 tokenHex,134 ourStaticPrivateKey,135 ourStaticPublicKey,136 localIdentity,137 adoptionSourceDeps,138 )139 trySend(event)140 141 142 143 if (event is PairingEvent.Paired) {144 expiryJob.cancel()145 stop()146 this@callbackFlow.close()147 }148 }149 }150 }151152 awaitClose {153 expiryJob.cancel()154 acceptJob.cancel()155 stop()156 }157 }158159 fun stop() {160 runCatching { serverSocket?.close() }161 serverSocket = null162 }163164 private suspend fun handleClient(165 client: Socket,166 expectedTokenHex: String,167 ourStaticPrivateKey: ByteArray,168 ourStaticPublicKey: ByteArray,169 localIdentity: LocalChirpIdentity,170 adoptionSourceDeps: AdoptionSourceDeps?,171 ): PairingEvent = withContext(Dispatchers.IO) {172 val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())173 try {174 if (!ws.accept()) {175 ws.closeSilently()176 return@withContext PairingEvent.Rejected("not_a_websocket")177 }178179 180 181 182 183 184 185 186 val helloText = when (val frame = ws.receive()) {187 is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)188 is WsMessage.Text -> frame.text189 else -> return@withContext rejectSilently(ws, "no_hello")190 }191 val helloJson = runCatching { JSONObject(helloText) }.getOrNull()192 ?: return@withContext rejectSilently(ws, "bad_hello")193 if (helloJson.optString("type") != "PEACH_HELLO") {194 return@withContext rejectSilently(ws, "bad_hello")195 }196 if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {197 return@withContext rejectSilently(ws, "bad_token")198 }199 val flow = helloJson.optString("flow").ifBlank { null }200 if (flow != null && flow != "adoption") {201 return@withContext rejectSilently(ws, "unsupported_flow")202 }203204 205 206 207 208 ws.send(209 JSONObject()210 .put("type", "PEACH_HELLO_ACK")211 .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })212 .toString()213 .toByteArray(Charsets.UTF_8),214 )215216 val session = noiseNKResponder(217 ourStaticPrivateKey,218 ourStaticPublicKey,219 { frame -> ws.send(frame) },220 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },221 )222223 if (flow == "adoption") {224 val deps = adoptionSourceDeps225 ?: return@withContext rejectSilently(ws, "adoption_unavailable")226 return@withContext handleAdoption(227 ws,228 session,229 localIdentity,230 deps,231 )232 }233234 235 236 237 val authenticatedInitiatorKey = performResponderPairingAuth(238 session = session,239 ourPrivateKey = ourStaticPrivateKey,240 ourPublicKey = ourStaticPublicKey,241 sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },242 receiveEncryptedJson = {243 session.decrypt(244 (ws.receive() as? WsMessage.Binary)?.bytes245 ?: throw IllegalStateException("peer closed"),246 )247 },248 )249250 251 val theirIdentity = parsePairIdentityFrame(252 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),253 )254 require(255 java.security.MessageDigest.isEqual(256 authenticatedInitiatorKey,257 hexToPubkey(theirIdentity.longTermPublicKeyHex),258 ),259 ) { "PAIR_IDENTITY key does not match proven initiator key" }260 ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))261262 val sas = SASDerivation.derive(session.handshakeHash)263 val device = Rendezvous.bindPairState(264 device = pairIdentityToPairedDevice(theirIdentity),265 handshakeHash = session.handshakeHash,266 267 relayUrl = localIdentity.relayUrl,268 )269 val pending = PairingSession()270 val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"271272 273 274 val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)275 confirmationSink?.invoke(confirmation)276 if (!pending.await()) {277 ws.closeSilently()278 return@withContext PairingEvent.Rejected("sas_mismatch")279 }280281 deviceStore.upsert(device)282 PairingEvent.Paired(name)283 } catch (e: Exception) {284 ws.closeSilently()285 PairingEvent.Rejected(e.message ?: "pairing_failed")286 } finally {287 runCatching { client.close() }288 }289 }290291 private suspend fun handleAdoption(292 ws: WebSocketConnection,293 session: NoiseNKSession,294 localIdentity: LocalChirpIdentity,295 sourceDeps: AdoptionSourceDeps,296 ): PairingEvent {297 val channel = object : PairingTransport {298 override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))299 override suspend fun receive(): ByteArray = session.decrypt(300 (ws.receive() as? WsMessage.Binary)?.bytes301 ?: throw IllegalStateException("peer closed"),302 )303 override suspend fun close() = ws.closeSilently()304 }305 val request = receiveFrame(channel)306 require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }307 request as AdoptionFrame.Request308309 val pending = PairingSession()310 val name = request.deviceName.ifBlank { "New device" }311 confirmationSink?.invoke(312 PairingEvent.AwaitingConfirmation(313 SASDerivation.derive(session.handshakeHash),314 name,315 pending,316 ),317 )318 if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")319320 val pairState = AdoptionPairState(321 rendezvousSecretHex = Rendezvous.bytesToHex(322 Rendezvous.deriveSecret(session.handshakeHash),323 ),324 relayUrl = localIdentity.relayUrl,325 )326 val boundDeps = object : AdoptionSourceDeps {327 override val sourceIdentity = localIdentity328 override val pairState = pairState329 override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()330 override suspend fun vaultId() = sourceDeps.vaultId()331 override suspend fun license() = sourceDeps.license()332 override suspend fun onConsent(request: AdoptionFrame.Request) = true333 override suspend fun upsertPairedDevice(device: PairedDevice) {334 sourceDeps.upsertPairedDevice(device)335 }336 }337 return if (grantAdoption(channel, request, boundDeps) != null) {338 PairingEvent.Paired(name)339 } else {340 PairingEvent.Rejected("adoption_rejected")341 }342 }343344 345346347348 var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null349350 private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {351 ws.closeSilently()352 return PairingEvent.Rejected(reason)353 }354355 356 private fun constantTimeEquals(a: String, b: String): Boolean {357 if (a.length != b.length) return false358 var diff = 0359 for (i in a.indices) diff = diff or (a[i].code xor b[i].code)360 return diff == 0361 }362363 364365366367368369370371372373 internal fun localIpv4(): String? = runCatching {374 val candidates = NetworkInterface.getNetworkInterfaces().toList()375 .filter { it.isUp && !it.isLoopback && !it.isVirtual }376 .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }377378 fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()379 .filterIsInstance<Inet4Address>()380 .firstOrNull { it.isSiteLocalAddress }381 ?.hostAddress382383 candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)384 ?: candidates.firstNotNullOfOrNull(::addressOf)385 }.getOrNull()386}