1package com.peachpasswords.sync23import android.content.Context4import android.net.nsd.NsdManager5import android.net.nsd.NsdServiceInfo6import android.os.Build7import dagger.hilt.android.qualifiers.ApplicationContext8import kotlinx.coroutines.CoroutineScope9import kotlinx.coroutines.Dispatchers10import kotlinx.coroutines.channels.awaitClose11import kotlinx.coroutines.flow.Flow12import kotlinx.coroutines.flow.callbackFlow13import kotlinx.coroutines.isActive14import kotlinx.coroutines.launch15import kotlinx.coroutines.withContext16import org.json.JSONObject17import java.net.ServerSocket18import java.net.Socket19import java.nio.charset.StandardCharsets20import javax.inject.Inject21import javax.inject.Singleton222324252627282930313233343536373839404142434445464748@Singleton49class MdnsPairingServer @Inject constructor(50 private val deviceStore: PairedDeviceStore,51 @ApplicationContext private val ctx: Context,52) {53 private var serverSocket: ServerSocket? = null54 private var nsdManager: NsdManager? = null55 private var registrationListener: NsdManager.RegistrationListener? = null5657 585960616263646566 fun start(67 scope: CoroutineScope,68 ourStaticPrivateKey: ByteArray,69 ourStaticPublicKey: ByteArray,70 localIdentity: LocalChirpIdentity,71 vaultFactory: () -> ChirpSyncVaultAdapter?,72 ): Flow<PairingServerEvent> = callbackFlow {73 val socket = withContext(Dispatchers.IO) { ServerSocket(0) }74 serverSocket = socket75 val port = socket.localPort7677 registerMdns(port, ourStaticPublicKey, localIdentity.deviceId)78 trySend(PairingServerEvent.Started(port))7980 val acceptJob = scope.launch(Dispatchers.IO) {81 while (isActive && !socket.isClosed) {82 val client = try {83 socket.accept()84 } catch (_: Throwable) {85 break86 }87 launch {88 val event = handleClient(client, ourStaticPrivateKey, ourStaticPublicKey, localIdentity, vaultFactory)89 trySend(event)90 }91 }92 }9394 awaitClose {95 acceptJob.cancel()96 stop()97 }98 }99100 fun stop() {101 registrationListener?.let { l -> runCatching { nsdManager?.unregisterService(l) } }102 registrationListener = null103 runCatching { serverSocket?.close() }104 serverSocket = null105 }106107 private suspend fun handleClient(108 socket: Socket,109 ourStaticPrivateKey: ByteArray,110 ourStaticPublicKey: ByteArray,111 localIdentity: LocalChirpIdentity,112 vaultFactory: () -> ChirpSyncVaultAdapter?,113 ): PairingServerEvent {114 val transport = SocketFrameIO(115 input = socket.getInputStream(),116 output = socket.getOutputStream(),117 onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },118 )119 var noiseSession: NoiseNKSession? = null120 return try {121 val selector = JSONObject(String(transport.receive(), Charsets.UTF_8))122 require(selector.optString("type") == "PEACH_RECONNECT") { "expected PEACH_RECONNECT" }123 val rendezvousId = selector.optString("rendezvous_id")124 val known = Rendezvous.resolvePeer(deviceStore.getAll(), rendezvousId)125 ?: throw IllegalArgumentException("unknown reconnect peer")126 val initiatorKeyHex = known.longTermPublicKeyHex127 ?: throw IllegalArgumentException("known peer missing static key")128 val initiatorKey = hexToPubkey(initiatorKeyHex)129130 val session = noiseKKResponder(131 ourStaticPrivateKey,132 ourStaticPublicKey,133 initiatorKey,134 transport::send,135 transport::receive,136 )137 noiseSession = session138139 140 141 val theirIdentity = parsePairIdentityFrame(142 SyncPadding.unpad(session.decrypt(transport.receive())),143 )144 require(theirIdentity.deviceId == known.deviceId) { "peer device id changed after KK" }145 require(146 CryptoCompare.hex(theirIdentity.longTermPublicKeyHex, initiatorKeyHex),147 ) { "peer identity does not match authenticated KK key" }148 transport.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))149150 val sas = SASDerivation.derive(session.handshakeHash)151 152 val vault = vaultFactory()153 if (vault != null) {154 vault.bindAuthenticatedPeer(known)155 val syncSession = chirpSessionOver(session, transport, initiatorKey)156 val outcome = runChirpSyncResponder(syncSession, vault)157 if (outcome.status == SyncOutcomeStatus.COMPLETED) {158 deviceStore.updateLastSync(known.deviceId)159 }160 }161 transport.close()162 PairingServerEvent.SyncComplete(known.deviceId, sas)163 } catch (e: Throwable) {164 transport.close()165 PairingServerEvent.Error(e.message ?: "pairing_server_error")166 } finally {167 noiseSession?.close()168 }169 }170171 private fun registerMdns(port: Int, staticPub: ByteArray, deviceId: String) {172 val manager = ctx.getSystemService(NsdManager::class.java) ?: return173 nsdManager = manager174 val info = NsdServiceInfo().apply {175 serviceName = buildServiceName(deviceId)176 serviceType = PEACH_SERVICE_TYPE177 this.port = port178 179 setAttribute("v", PROTOCOL_VERSION)180 setAttribute("pk", pubkeyToHex(staticPub))181 setAttribute("id", deviceId)182 }183 val listener = object : NsdManager.RegistrationListener {184 override fun onServiceRegistered(info: NsdServiceInfo) {}185 override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {}186 override fun onServiceUnregistered(info: NsdServiceInfo) {}187 override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {}188 }189 registrationListener = listener190 manager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)191 }192193 private fun buildServiceName(deviceId: String): String {194 val shortId = deviceId.take(8)195 val model = Build.MODEL.replace(Regex("[^A-Za-z0-9-]"), "")196 197 198 val maxModel = 63 - "Peach-".length - 1 - shortId.length199 val trimmedModel = if (model.length > maxModel && maxModel > 0) model.take(maxModel) else model200 return "Peach-$trimmedModel-$shortId".take(63)201 }202203 private companion object {204 const val PEACH_SERVICE_TYPE = "_peach._tcp"205 const val PROTOCOL_VERSION = "1"206 }207}208209sealed class PairingServerEvent {210 data class Started(val port: Int) : PairingServerEvent()211 data class SyncComplete(val deviceId: String, val sas: String) : PairingServerEvent()212 data class Error(val message: String) : PairingServerEvent()213 data object Stopped : PairingServerEvent()214}215216217218219internal data class PairIdentity(220 val deviceId: String,221 val deviceName: String,222 val deviceType: DeviceType,223 val longTermPublicKeyHex: String,224 val signingPublicKeyHex: String?,225 val wsPort: Int?,226 val relayUrl: String?,227)228229internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {230 val sb = StringBuilder()231 sb.append('{')232 sb.append("\"type\":\"PAIR_IDENTITY\"")233 sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"')234 sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"')235 sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"')236 sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')237 sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')238 id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }239 sb.append(",\"relay_url\":\"").append(jsonEscape(id.relayUrl)).append('"')240 sb.append('}')241 return sb.toString().toByteArray(StandardCharsets.UTF_8)242}243244internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {245 val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8))246 require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }247 val deviceType = when (map["device_type"]) {248 "extension" -> DeviceType.EXTENSION249 "android" -> DeviceType.ANDROID250 "ios" -> DeviceType.IOS251 else -> error("device_type missing")252 }253 val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")254 require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" }255 return PairIdentity(256 deviceId = map["device_id"] ?: error("device_id missing"),257 deviceName = map["device_name"] ?: error("device_name missing"),258 deviceType = deviceType,259 longTermPublicKeyHex = ltpk,260 signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },261 wsPort = map["ws_port"]?.toIntOrNull(),262 relayUrl = map["relay_url"]?.let(Rendezvous::normalizeRelayUrl),263 )264}265266internal fun pairIdentityToPairedDevice(id: PairIdentity): PairedDevice = PairedDevice(267 deviceId = id.deviceId,268 deviceName = id.deviceName,269 deviceType = id.deviceType.wireValue,270 longTermPublicKeyHex = id.longTermPublicKeyHex,271 signingPublicKeyHex = id.signingPublicKeyHex,272 trustSource = "direct",273 wsPort = id.wsPort,274 relayUrl = id.relayUrl,275 pairedAt = System.currentTimeMillis(),276 lastSyncedAt = System.currentTimeMillis(),277)278279280281282283284285internal fun chirpSessionOver(286 session: NoiseNKSession,287 transport: SocketFrameIO,288 remoteStaticPublicKey: ByteArray = ByteArray(0),289): ChirpSyncSession =290 ChirpSyncSession(291 remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),292 293 294 send = { plaintext -> transport.sendBlocking(session.encrypt(SyncPadding.pad(plaintext))) },295 receive = { SyncPadding.unpad(session.decrypt(transport.receive())) },296 close = {297 try {298 session.close()299 } finally {300 transport.close()301 }302 },303 )304305private fun jsonEscape(value: String): String = buildString(value.length) {306 for (ch in value) when (ch) {307 '\\' -> append("\\\\"); '"' -> append("\\\"")308 '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")309 else -> append(ch)310 }311}312313private fun miniJsonObject(text: String): Map<String, String> {314 val trimmed = text.trim()315 require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }316 val body = trimmed.substring(1, trimmed.length - 1)317 val out = linkedMapOf<String, String>()318 var i = 0319 while (i < body.length) {320 while (i < body.length && body[i].isWhitespace()) i++321 if (i >= body.length || body[i] != '"') break322 i++323 val keyStart = i324 while (i < body.length && body[i] != '"') i++325 val key = body.substring(keyStart, i); i++326 while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++327 val value: String328 if (i < body.length && body[i] == '"') {329 i++330 val vStart = i331 while (i < body.length && body[i] != '"') i++332 value = body.substring(vStart, i); i++333 } else {334 val vStart = i335 while (i < body.length && body[i] != ',') i++336 value = body.substring(vStart, i).trim()337 }338 out[key] = value339 while (i < body.length && body[i].isWhitespace()) i++340 if (i < body.length && body[i] == ',') i++341 }342 return out343}