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 var noiseSession: NoiseNKSession? = null174 try {175 if (!ws.accept()) {176 ws.closeSilently()177 return@withContext PairingEvent.Rejected("not_a_websocket")178 }179180 181 182 183 184 185 186 187 val helloText = when (val frame = ws.receive()) {188 is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)189 is WsMessage.Text -> frame.text190 else -> return@withContext rejectSilently(ws, "no_hello")191 }192 val helloJson = runCatching { JSONObject(helloText) }.getOrNull()193 ?: return@withContext rejectSilently(ws, "bad_hello")194 if (helloJson.optString("type") != "PEACH_HELLO") {195 return@withContext rejectSilently(ws, "bad_hello")196 }197 if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {198 return@withContext rejectSilently(ws, "bad_token")199 }200 val flow = helloJson.optString("flow").ifBlank { null }201 if (flow != null && flow != "adoption") {202 return@withContext rejectSilently(ws, "unsupported_flow")203 }204205 206 207 208 209 ws.send(210 JSONObject()211 .put("type", "PEACH_HELLO_ACK")212 .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })213 .toString()214 .toByteArray(Charsets.UTF_8),215 )216217 val session = noiseNKResponder(218 ourStaticPrivateKey,219 ourStaticPublicKey,220 { frame -> ws.send(frame) },221 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },222 )223 noiseSession = session224225 if (flow == "adoption") {226 val deps = adoptionSourceDeps227 ?: return@withContext rejectSilently(ws, "adoption_unavailable")228 return@withContext handleAdoption(229 ws,230 session,231 localIdentity,232 deps,233 )234 }235236 237 238 239 val authenticatedInitiatorKey = performResponderPairingAuth(240 session = session,241 ourPrivateKey = ourStaticPrivateKey,242 ourPublicKey = ourStaticPublicKey,243 sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },244 receiveEncryptedJson = {245 session.decrypt(246 (ws.receive() as? WsMessage.Binary)?.bytes247 ?: throw IllegalStateException("peer closed"),248 )249 },250 )251252 253 val theirIdentity = parsePairIdentityFrame(254 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),255 )256 require(257 java.security.MessageDigest.isEqual(258 authenticatedInitiatorKey,259 hexToPubkey(theirIdentity.longTermPublicKeyHex),260 ),261 ) { "PAIR_IDENTITY key does not match proven initiator key" }262 ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))263264 val sas = SASDerivation.derive(session.handshakeHash)265 val device = Rendezvous.bindPairState(266 device = pairIdentityToPairedDevice(theirIdentity),267 handshakeHash = session.handshakeHash,268 269 relayUrl = localIdentity.relayUrl,270 )271 val pending = PairingSession()272 val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"273274 275 276 val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)277 confirmationSink?.invoke(confirmation)278 if (!pending.await()) {279 ws.closeSilently()280 return@withContext PairingEvent.Rejected("sas_mismatch")281 }282283 284 285 286 deviceStore.clearRevocation(device.deviceId)287 deviceStore.upsert(device)288 PairingEvent.Paired(name)289 } catch (e: Exception) {290 ws.closeSilently()291 PairingEvent.Rejected(e.message ?: "pairing_failed")292 } finally {293 noiseSession?.close()294 runCatching { client.close() }295 }296 }297298 private suspend fun handleAdoption(299 ws: WebSocketConnection,300 session: NoiseNKSession,301 localIdentity: LocalChirpIdentity,302 sourceDeps: AdoptionSourceDeps,303 ): PairingEvent {304 val channel = object : PairingTransport {305 override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))306 override suspend fun receive(): ByteArray = session.decrypt(307 (ws.receive() as? WsMessage.Binary)?.bytes308 ?: throw IllegalStateException("peer closed"),309 )310 override suspend fun close() = ws.closeSilently()311 }312 val request = receiveFrame(channel)313 require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }314 request as AdoptionFrame.Request315316 val pending = PairingSession()317 val name = request.deviceName.ifBlank { "New device" }318 confirmationSink?.invoke(319 PairingEvent.AwaitingConfirmation(320 SASDerivation.derive(session.handshakeHash),321 name,322 pending,323 ),324 )325 if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")326327 val pairState = AdoptionPairState(328 rendezvousSecretHex = Rendezvous.bytesToHex(329 Rendezvous.deriveSecret(session.handshakeHash),330 ),331 relayUrl = localIdentity.relayUrl,332 )333 val boundDeps = object : AdoptionSourceDeps {334 override val sourceIdentity = localIdentity335 override val pairState = pairState336 override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()337 override suspend fun vaultId() = sourceDeps.vaultId()338 override suspend fun license() = sourceDeps.license()339 override suspend fun onConsent(request: AdoptionFrame.Request) = true340 override suspend fun upsertPairedDevice(device: PairedDevice) {341 sourceDeps.upsertPairedDevice(device)342 }343 }344 return if (grantAdoption(channel, request, boundDeps) != null) {345 PairingEvent.Paired(name)346 } else {347 PairingEvent.Rejected("adoption_rejected")348 }349 }350351 352353354355 var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null356357 private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {358 ws.closeSilently()359 return PairingEvent.Rejected(reason)360 }361362 363 private fun constantTimeEquals(a: String, b: String): Boolean {364 return CryptoCompare.utf8(a, b)365 }366367 368369370371372373374375376377 internal fun localIpv4(): String? = runCatching {378 val candidates = NetworkInterface.getNetworkInterfaces().toList()379 .filter { it.isUp && !it.isLoopback && !it.isVirtual }380 .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }381382 fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()383 .filterIsInstance<Inet4Address>()384 .firstOrNull { it.isSiteLocalAddress }385 ?.hostAddress386387 candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)388 ?: candidates.firstNotNullOfOrNull(::addressOf)389 }.getOrNull()390}