MdnsPairingServer.kt

13.7 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 java.net.ServerSocket17import java.net.Socket18import java.nio.charset.StandardCharsets19import javax.inject.Inject20import javax.inject.Singleton2122/**23 * C7 TCP + mDNS pairing/sync SERVER (responder side).24 *25 * Listens on an OS-assigned TCP port, advertises itself over mDNS as26 * `_peach._tcp`, and for each inbound connection runs the REAL responder27 * pipeline:28 *   1. Noise NK responder handshake ([noiseNKResponder]) over length-prefixed29 *      frames ([SocketFrameIO]).30 *   2. PAIR_IDENTITY frame exchange — encoded/decoded with the SAME private31 *      helpers ChirpPairing uses; since those are file-private there, this draft32 *      re-derives the identity frame via [LocalChirpIdentity] and a minimal33 *      JSON, and parses the peer's via [parsePairIdentityFrame]. See34 *      C7-INTEGRATION.md: the cleanest integration promotes ChirpPairing's35 *      encode/decodePairingFrame to internal and deletes these shims.36 *   3. Encrypted vault sync via the REAL [runChirpSyncResponder] over a37 *      [ChirpSyncSession] backed by the Noise session.38 *39 * ── Reconciliation with the brief ──────────────────────────────────────────40 *  - The brief calls `noiseNK.acceptHandshake(stream, out)` returning an object41 *    with `.sendCipher/.recvCipher/.handshakeHash`. NO SUCH API EXISTS. The real42 *    handshake is the suspend free function `noiseNKResponder(ourPriv, ourPub,43 *    send, receive)` returning a single [NoiseNKSession] (encrypt/decrypt/44 *    handshakeHash) — one bidirectional cipher, not split send/recv ciphers.45 *  - NK is a responder-static / no-initiator-static pattern: the responder needs46 *    its OWN static keypair, and the initiator must already know the responder's47 *    static public key. We use the device's long-term sync identity48 *    ([deriveSyncIdentityKeypair]) as that static key and publish its public half49 *    in the mDNS TXT "pk" record so initiators can pin it.50 *  - The brief calls `syncAdapter.buildResponse/applyRequest`. NO SUCH METHODS.51 *    The real per-round protocol is [runChirpSyncResponder]; the adapter is a52 *    [ChirpSyncVaultAdapter] ([VaultSyncAdapter]) with getEncryptedEntries/53 *    applyEntries/etc. We drive the whole round, not single request/response.54 */55@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    /**65     * Start listening + advertise + accept. Caller supplies:66     *  - [ourStaticPrivateKey]/[ourStaticPublicKey]: the device's long-term sync67     *    identity (from [deriveSyncIdentityKeypair]) — the Noise NK responder68     *    static key, also published in mDNS TXT "pk".69     *  - [vaultFactory]: builds a fresh [ChirpSyncVaultAdapter] per connection70     *    (the vault may lock/unlock between connections).71     *  - [localIdentity]: our PAIR_IDENTITY payload.72     */73    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            // 1. Noise NK responder handshake — real free function, single session.128            val session = noiseNKResponder(129                ourStaticPrivateKey,130                ourStaticPublicKey,131                transport::send,132                transport::receive,133            )134135            // 2. PAIR_IDENTITY exchange (responder reads first, then answers) —136            //    mirrors ChirpPairing.runPairingResponder ordering.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            // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.145            val vault = vaultFactory()146            if (vault != null) {147                val syncSession = chirpSessionOver(session, transport)148                val outcome = runChirpSyncResponder(syncSession, vault)149                if (outcome.status == SyncOutcomeStatus.COMPLETED) {150                    deviceStore.updateLastSync(device.deviceId)151                }152            }153            transport.close()154            PairingServerEvent.SyncComplete(device.deviceId, sas)155        } catch (e: Throwable) {156            transport.close()157            PairingServerEvent.Error(e.message ?: "pairing_server_error")158        }159    }160161    private fun registerMdns(port: Int, staticPub: ByteArray, deviceId: String) {162        val manager = ctx.getSystemService(NsdManager::class.java) ?: return163        nsdManager = manager164        val info = NsdServiceInfo().apply {165            serviceName = buildServiceName(deviceId)166            serviceType = PEACH_SERVICE_TYPE167            this.port = port168            // NsdServiceInfo.setAttribute is API 21+; minSdk is 26.169            setAttribute("v", PROTOCOL_VERSION)170            setAttribute("pk", pubkeyToHex(staticPub))171            setAttribute("id", deviceId)172        }173        val listener = object : NsdManager.RegistrationListener {174            override fun onServiceRegistered(info: NsdServiceInfo) {}175            override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {}176            override fun onServiceUnregistered(info: NsdServiceInfo) {}177            override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {}178        }179        registrationListener = listener180        manager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)181    }182183    private fun buildServiceName(deviceId: String): String {184        val shortId = deviceId.take(8)185        val model = Build.MODEL.replace(Regex("[^A-Za-z0-9-]"), "")186        // mDNS service-instance names are capped at 63 bytes; reserve room for187        // the "Peach-" prefix and the "-${shortId}" suffix.188        val maxModel = 63 - "Peach-".length - 1 - shortId.length189        val trimmedModel = if (model.length > maxModel && maxModel > 0) model.take(maxModel) else model190        return "Peach-$trimmedModel-$shortId".take(63)191    }192193    private companion object {194        const val PEACH_SERVICE_TYPE = "_peach._tcp"195        const val PROTOCOL_VERSION = "1"196    }197}198199sealed class PairingServerEvent {200    data class Started(val port: Int) : PairingServerEvent()201    data class SyncComplete(val deviceId: String, val sas: String) : PairingServerEvent()202    data class Error(val message: String) : PairingServerEvent()203    data object Stopped : PairingServerEvent()204}205206/* ── PAIR_IDENTITY shims (see C7-INTEGRATION.md item: promote ChirpPairing's207   private encode/decodePairingFrame to internal and drop these) ───────────── */208209internal data class PairIdentity(210    val deviceId: String,211    val deviceName: String,212    val deviceType: DeviceType,213    val longTermPublicKeyHex: String,214    val signingPublicKeyHex: String?,215    val wsPort: Int?,216)217218internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {219    val sb = StringBuilder()220    sb.append('{')221    sb.append("\"type\":\"PAIR_IDENTITY\"")222    sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"')223    sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"')224    sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"')225    sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')226    sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')227    id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }228    sb.append('}')229    return sb.toString().toByteArray(StandardCharsets.UTF_8)230}231232internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {233    val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8))234    require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }235    val deviceType = when (map["device_type"]) {236        "extension" -> DeviceType.EXTENSION237        "android" -> DeviceType.ANDROID238        "ios" -> DeviceType.IOS239        "desktop" -> DeviceType.DESKTOP240        else -> error("device_type missing")241    }242    val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")243    require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" }244    return PairIdentity(245        deviceId = map["device_id"] ?: error("device_id missing"),246        deviceName = map["device_name"] ?: error("device_name missing"),247        deviceType = deviceType,248        longTermPublicKeyHex = ltpk,249        signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },250        wsPort = map["ws_port"]?.toIntOrNull(),251    )252}253254internal fun pairIdentityToPairedDevice(id: PairIdentity): PairedDevice = PairedDevice(255    deviceId = id.deviceId,256    deviceName = id.deviceName,257    deviceType = id.deviceType.wireValue,258    longTermPublicKeyHex = id.longTermPublicKeyHex,259    signingPublicKeyHex = id.signingPublicKeyHex,260    trustSource = "direct",261    wsPort = id.wsPort,262    pairedAt = System.currentTimeMillis(),263    lastSyncedAt = System.currentTimeMillis(),264)265266/**267 * Build a [ChirpSyncSession] over an established Noise session + frame268 * transport, so the REAL [runChirpSyncInitiator]/[runChirpSyncResponder] can run269 * the encrypted vault exchange. Send/receive splice the Noise cipher onto the270 * length-prefixed frame layer; `remoteStaticPublicKey` is informational here.271 */272internal fun chirpSessionOver(session: NoiseNKSession, transport: SocketFrameIO): ChirpSyncSession =273    ChirpSyncSession(274        remoteStaticPublicKey = ByteArray(0),275        // ChirpSyncSession.send is non-suspending (matches openChirpSyncSession);276        // SocketFrameIO.sendBlocking writes a frame synchronously.277        send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },278        receive = { session.decrypt(transport.receive()) },279        close = { transport.close() },280    )281282private fun jsonEscape(value: String): String = buildString(value.length) {283    for (ch in value) when (ch) {284        '\\' -> append("\\\\"); '"' -> append("\\\"")285        '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")286        else -> append(ch)287    }288}289290private fun miniJsonObject(text: String): Map<String, String> {291    val trimmed = text.trim()292    require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }293    val body = trimmed.substring(1, trimmed.length - 1)294    val out = linkedMapOf<String, String>()295    var i = 0296    while (i < body.length) {297        while (i < body.length && body[i].isWhitespace()) i++298        if (i >= body.length || body[i] != '"') break299        i++300        val keyStart = i301        while (i < body.length && body[i] != '"') i++302        val key = body.substring(keyStart, i); i++303        while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++304        val value: String305        if (i < body.length && body[i] == '"') {306            i++307            val vStart = i308            while (i < body.length && body[i] != '"') i++309            value = body.substring(vStart, i); i++310        } else {311            val vStart = i312            while (i < body.length && body[i] != ',') i++313            value = body.substring(vStart, i).trim()314        }315        out[key] = value316        while (i < body.length && body[i].isWhitespace()) i++317        if (i < body.length && body[i] == ',') i++318    }319    return out320}