MdnsPairingServer.kt

14.4 KB · raw

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.Singleton2223/**24 * C7 TCP + mDNS reconnect SERVER (responder side).25 *26 * Listens on an OS-assigned TCP port, advertises itself over mDNS as27 * `_peach._tcp`, and for each inbound connection runs the REAL responder28 * pipeline:29 *   1. Resolve PEACH_RECONNECT against paired local state, then run Noise KK30 *      over length-prefixed frames ([SocketFrameIO]).31 *   2. PAIR_IDENTITY frame exchange — encoded/decoded with the SAME private32 *      helpers ChirpPairing uses; since those are file-private there, this draft33 *      re-derives the identity frame via [LocalChirpIdentity] and a minimal34 *      JSON, and parses the peer's via [parsePairIdentityFrame]. See35 *      C7-INTEGRATION.md: the cleanest integration promotes ChirpPairing's36 *      encode/decodePairingFrame to internal and deletes these shims.37 *   3. Encrypted vault sync via the REAL [runChirpSyncResponder] over a38 *      [ChirpSyncSession] backed by the Noise session.39 *40 * Reconnect is strictly KK. The opaque selector capability is resolved against41 * local paired state before the handshake, both static keys are authenticated42 * by the named pattern, and failure never falls back to first-pair NK.43 *  - The brief calls `syncAdapter.buildResponse/applyRequest`. NO SUCH METHODS.44 *    The real per-round protocol is [runChirpSyncResponder]; the adapter is a45 *    [ChirpSyncVaultAdapter] ([VaultSyncAdapter]) with getEncryptedEntries/46 *    applyEntries/etc. We drive the whole round, not single request/response.47 */48@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    /**58     * Start listening + advertise + accept. Caller supplies:59     *  - [ourStaticPrivateKey]/[ourStaticPublicKey]: the device's long-term sync60     *    identity (from [deriveSyncIdentityKeypair]) — the KK responder static61     *    key, also published in mDNS TXT "pk".62     *  - [vaultFactory]: builds a fresh [ChirpSyncVaultAdapter] per connection63     *    (the vault may lock/unlock between connections).64     *  - [localIdentity]: our PAIR_IDENTITY payload.65     */66    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            // 2. PAIR_IDENTITY exchange (responder reads first, then answers) —140            //    mirrors ChirpPairing.runPairingResponder ordering.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            // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.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            // NsdServiceInfo.setAttribute is API 21+; minSdk is 26.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        // mDNS service-instance names are capped at 63 bytes; reserve room for197        // the "Peach-" prefix and the "-${shortId}" suffix.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}215216/* ── PAIR_IDENTITY shims (see C7-INTEGRATION.md item: promote ChirpPairing's217   private encode/decodePairingFrame to internal and drop these) ───────────── */218219internal 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)278279/**280 * Build a [ChirpSyncSession] over an established Noise session + frame281 * transport, so the REAL [runChirpSyncInitiator]/[runChirpSyncResponder] can run282 * the encrypted vault exchange. Send/receive splice the Noise cipher onto the283 * length-prefixed frame layer; `remoteStaticPublicKey` is informational here.284 */285internal fun chirpSessionOver(286    session: NoiseNKSession,287    transport: SocketFrameIO,288    remoteStaticPublicKey: ByteArray = ByteArray(0),289): ChirpSyncSession =290    ChirpSyncSession(291        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),292        // ChirpSyncSession.send is non-suspending;293        // SocketFrameIO.sendBlocking writes a frame synchronously.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}