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 java.net.ServerSocket17import java.net.Socket18import java.nio.charset.StandardCharsets19import javax.inject.Inject20import javax.inject.Singleton2122232425262728293031323334353637383940414243444546474849505152535455@Singleton56class MdnsPairingServer @Inject constructor(57 private val deviceStore: PairedDeviceStore,58 @ApplicationContext private val ctx: Context,59) {60 private var serverSocket: ServerSocket? = null61 private var nsdManager: NsdManager? = null62 private var registrationListener: NsdManager.RegistrationListener? = null6364 656667686970717273 fun start(74 scope: CoroutineScope,75 ourStaticPrivateKey: ByteArray,76 ourStaticPublicKey: ByteArray,77 localIdentity: LocalChirpIdentity,78 vaultFactory: () -> ChirpSyncVaultAdapter?,79 ): Flow<PairingServerEvent> = callbackFlow {80 val socket = withContext(Dispatchers.IO) { ServerSocket(0) }81 serverSocket = socket82 val port = socket.localPort8384 registerMdns(port, ourStaticPublicKey, localIdentity.deviceId)85 trySend(PairingServerEvent.Started(port))8687 val acceptJob = scope.launch(Dispatchers.IO) {88 while (isActive && !socket.isClosed) {89 val client = try {90 socket.accept()91 } catch (_: Throwable) {92 break93 }94 launch {95 val event = handleClient(client, ourStaticPrivateKey, ourStaticPublicKey, localIdentity, vaultFactory)96 trySend(event)97 }98 }99 }100101 awaitClose {102 acceptJob.cancel()103 stop()104 }105 }106107 fun stop() {108 registrationListener?.let { l -> runCatching { nsdManager?.unregisterService(l) } }109 registrationListener = null110 runCatching { serverSocket?.close() }111 serverSocket = null112 }113114 private suspend fun handleClient(115 socket: Socket,116 ourStaticPrivateKey: ByteArray,117 ourStaticPublicKey: ByteArray,118 localIdentity: LocalChirpIdentity,119 vaultFactory: () -> ChirpSyncVaultAdapter?,120 ): PairingServerEvent {121 val transport = SocketFrameIO(122 input = socket.getInputStream(),123 output = socket.getOutputStream(),124 onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },125 )126 return try {127 128 val session = noiseNKResponder(129 ourStaticPrivateKey,130 ourStaticPublicKey,131 transport::send,132 transport::receive,133 )134135 136 137 val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))138 transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))139140 val sas = SASDerivation.derive(session.handshakeHash)141 val device = pairIdentityToPairedDevice(theirIdentity)142 deviceStore.upsert(device)143144 145 val vault = vaultFactory()146 if (vault != null) {147 vault.bindAuthenticatedPeer(device)148 val syncSession = chirpSessionOver(session, transport)149 val outcome = runChirpSyncResponder(syncSession, vault)150 if (outcome.status == SyncOutcomeStatus.COMPLETED) {151 deviceStore.updateLastSync(device.deviceId)152 }153 }154 transport.close()155 PairingServerEvent.SyncComplete(device.deviceId, sas)156 } catch (e: Throwable) {157 transport.close()158 PairingServerEvent.Error(e.message ?: "pairing_server_error")159 }160 }161162 private fun registerMdns(port: Int, staticPub: ByteArray, deviceId: String) {163 val manager = ctx.getSystemService(NsdManager::class.java) ?: return164 nsdManager = manager165 val info = NsdServiceInfo().apply {166 serviceName = buildServiceName(deviceId)167 serviceType = PEACH_SERVICE_TYPE168 this.port = port169 170 setAttribute("v", PROTOCOL_VERSION)171 setAttribute("pk", pubkeyToHex(staticPub))172 setAttribute("id", deviceId)173 }174 val listener = object : NsdManager.RegistrationListener {175 override fun onServiceRegistered(info: NsdServiceInfo) {}176 override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {}177 override fun onServiceUnregistered(info: NsdServiceInfo) {}178 override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {}179 }180 registrationListener = listener181 manager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)182 }183184 private fun buildServiceName(deviceId: String): String {185 val shortId = deviceId.take(8)186 val model = Build.MODEL.replace(Regex("[^A-Za-z0-9-]"), "")187 188 189 val maxModel = 63 - "Peach-".length - 1 - shortId.length190 val trimmedModel = if (model.length > maxModel && maxModel > 0) model.take(maxModel) else model191 return "Peach-$trimmedModel-$shortId".take(63)192 }193194 private companion object {195 const val PEACH_SERVICE_TYPE = "_peach._tcp"196 const val PROTOCOL_VERSION = "1"197 }198}199200sealed class PairingServerEvent {201 data class Started(val port: Int) : PairingServerEvent()202 data class SyncComplete(val deviceId: String, val sas: String) : PairingServerEvent()203 data class Error(val message: String) : PairingServerEvent()204 data object Stopped : PairingServerEvent()205}206207208209210internal data class PairIdentity(211 val deviceId: String,212 val deviceName: String,213 val deviceType: DeviceType,214 val longTermPublicKeyHex: String,215 val signingPublicKeyHex: String?,216 val wsPort: Int?,217)218219internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {220 val sb = StringBuilder()221 sb.append('{')222 sb.append("\"type\":\"PAIR_IDENTITY\"")223 sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"')224 sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"')225 sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"')226 sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')227 sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')228 id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }229 sb.append('}')230 return sb.toString().toByteArray(StandardCharsets.UTF_8)231}232233internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {234 val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8))235 require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }236 val deviceType = when (map["device_type"]) {237 "extension" -> DeviceType.EXTENSION238 "android" -> DeviceType.ANDROID239 "ios" -> DeviceType.IOS240 "desktop" -> DeviceType.DESKTOP241 else -> error("device_type missing")242 }243 val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")244 require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" }245 return PairIdentity(246 deviceId = map["device_id"] ?: error("device_id missing"),247 deviceName = map["device_name"] ?: error("device_name missing"),248 deviceType = deviceType,249 longTermPublicKeyHex = ltpk,250 signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },251 wsPort = map["ws_port"]?.toIntOrNull(),252 )253}254255internal fun pairIdentityToPairedDevice(id: PairIdentity): PairedDevice = PairedDevice(256 deviceId = id.deviceId,257 deviceName = id.deviceName,258 deviceType = id.deviceType.wireValue,259 longTermPublicKeyHex = id.longTermPublicKeyHex,260 signingPublicKeyHex = id.signingPublicKeyHex,261 trustSource = "direct",262 wsPort = id.wsPort,263 pairedAt = System.currentTimeMillis(),264 lastSyncedAt = System.currentTimeMillis(),265)266267268269270271272273internal fun chirpSessionOver(274 session: NoiseNKSession,275 transport: SocketFrameIO,276 remoteStaticPublicKey: ByteArray = ByteArray(0),277): ChirpSyncSession =278 ChirpSyncSession(279 remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),280 281 282 send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },283 receive = { session.decrypt(transport.receive()) },284 close = { transport.close() },285 )286287private fun jsonEscape(value: String): String = buildString(value.length) {288 for (ch in value) when (ch) {289 '\\' -> append("\\\\"); '"' -> append("\\\"")290 '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")291 else -> append(ch)292 }293}294295private fun miniJsonObject(text: String): Map<String, String> {296 val trimmed = text.trim()297 require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }298 val body = trimmed.substring(1, trimmed.length - 1)299 val out = linkedMapOf<String, String>()300 var i = 0301 while (i < body.length) {302 while (i < body.length && body[i].isWhitespace()) i++303 if (i >= body.length || body[i] != '"') break304 i++305 val keyStart = i306 while (i < body.length && body[i] != '"') i++307 val key = body.substring(keyStart, i); i++308 while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++309 val value: String310 if (i < body.length && body[i] == '"') {311 i++312 val vStart = i313 while (i < body.length && body[i] != '"') i++314 value = body.substring(vStart, i); i++315 } else {316 val vStart = i317 while (i < body.length && body[i] != ',') i++318 value = body.substring(vStart, i).trim()319 }320 out[key] = value321 while (i < body.length && body[i].isWhitespace()) i++322 if (i < body.length && body[i] == ',') i++323 }324 return out325}