MdnsPairingServer.kt

13.9 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                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            // NsdServiceInfo.setAttribute is API 21+; minSdk is 26.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        // mDNS service-instance names are capped at 63 bytes; reserve room for188        // the "Peach-" prefix and the "-${shortId}" suffix.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}206207/* ── PAIR_IDENTITY shims (see C7-INTEGRATION.md item: promote ChirpPairing's208   private encode/decodePairingFrame to internal and drop these) ───────────── */209210internal 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)266267/**268 * Build a [ChirpSyncSession] over an established Noise session + frame269 * transport, so the REAL [runChirpSyncInitiator]/[runChirpSyncResponder] can run270 * the encrypted vault exchange. Send/receive splice the Noise cipher onto the271 * length-prefixed frame layer; `remoteStaticPublicKey` is informational here.272 */273internal fun chirpSessionOver(274    session: NoiseNKSession,275    transport: SocketFrameIO,276    remoteStaticPublicKey: ByteArray = ByteArray(0),277): ChirpSyncSession =278    ChirpSyncSession(279        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),280        // ChirpSyncSession.send is non-suspending (matches openChirpSyncSession);281        // SocketFrameIO.sendBlocking writes a frame synchronously.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}