MdnsPairingServer.kt

13.8 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        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(273    session: NoiseNKSession,274    transport: SocketFrameIO,275    remoteStaticPublicKey: ByteArray = ByteArray(0),276): ChirpSyncSession =277    ChirpSyncSession(278        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),279        // ChirpSyncSession.send is non-suspending;280        // SocketFrameIO.sendBlocking writes a frame synchronously.281        send = { plaintext -> transport.sendBlocking(session.encrypt(plaintext)) },282        receive = { session.decrypt(transport.receive()) },283        close = { transport.close() },284    )285286private fun jsonEscape(value: String): String = buildString(value.length) {287    for (ch in value) when (ch) {288        '\\' -> append("\\\\"); '"' -> append("\\\"")289        '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")290        else -> append(ch)291    }292}293294private fun miniJsonObject(text: String): Map<String, String> {295    val trimmed = text.trim()296    require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }297    val body = trimmed.substring(1, trimmed.length - 1)298    val out = linkedMapOf<String, String>()299    var i = 0300    while (i < body.length) {301        while (i < body.length && body[i].isWhitespace()) i++302        if (i >= body.length || body[i] != '"') break303        i++304        val keyStart = i305        while (i < body.length && body[i] != '"') i++306        val key = body.substring(keyStart, i); i++307        while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++308        val value: String309        if (i < body.length && body[i] == '"') {310            i++311            val vStart = i312            while (i < body.length && body[i] != '"') i++313            value = body.substring(vStart, i); i++314        } else {315            val vStart = i316            while (i < body.length && body[i] != ',') i++317            value = body.substring(vStart, i).trim()318        }319        out[key] = value320        while (i < body.length && body[i].isWhitespace()) i++321        if (i < body.length && body[i] == ',') i++322    }323    return out324}