PairingDiscoveryClient.kt

8.8 KB · raw

1package com.peachpasswords.sync23import android.content.Context4import android.net.nsd.NsdManager5import android.net.nsd.NsdServiceInfo6import android.net.wifi.WifiManager7import dagger.hilt.android.qualifiers.ApplicationContext8import kotlinx.coroutines.Dispatchers9import kotlinx.coroutines.channels.awaitClose10import kotlinx.coroutines.flow.Flow11import kotlinx.coroutines.flow.callbackFlow12import kotlinx.coroutines.flow.first13import kotlinx.coroutines.withContext14import kotlinx.coroutines.withTimeoutOrNull15import java.net.InetSocketAddress16import java.net.Socket17import java.net.SocketTimeoutException18import javax.inject.Inject19import javax.inject.Singleton2021/**22 * C7 TCP + mDNS pairing/sync CLIENT (initiator side). Discovers `_peach._tcp`23 * peers, dials one, and runs the REAL initiator pipeline:24 *   1. Noise NK initiator handshake ([noiseNKInitiator]) — pins the responder's25 *      static public key from the mDNS TXT "pk" record.26 *   2. PAIR_IDENTITY exchange (initiator writes first, then reads), matching27 *      ChirpPairing.runPairingInitiator ordering.28 *   3. SAS via the REAL [SASDerivation.derive] over the Noise handshake hash.29 *   4. Encrypted vault sync via the REAL [runChirpSyncInitiator] over a30 *      [ChirpSyncSession].31 *32 * ── Reconciliation with the brief ──────────────────────────────────────────33 *  - `noiseNK.initiateHandshake(stream, out, remotePublicKey)` does NOT exist.34 *    Real: `noiseNKInitiator(responderStaticPublicKey, send, receive)` →35 *    [NoiseNKSession]. One bidirectional cipher; no `.sendCipher/.recvCipher`.36 *  - `syncAdapter.buildRequest/applyResponse` do NOT exist. Real round driver is37 *    [runChirpSyncInitiator] against a [ChirpSyncVaultAdapter].38 *  - `peer.serviceInfo.host/.port`: on API < 34, [NsdServiceInfo.getHost] is39 *    deprecated; on the supported floor it still returns the resolved40 *    InetAddress, which we use for the dial. The resolved port is authoritative.41 */42@Singleton43class PairingDiscoveryClient @Inject constructor(44    @ApplicationContext private val ctx: Context,45    private val deviceStore: PairedDeviceStore,46) {47    /**48     * Discover nearby Peach instances via mDNS. Each found service is resolved,49     * its TXT records parsed into a [DiscoveredPeer], and emitted. The flow runs50     * until the collector cancels (e.g. via kotlinx `.timeout(...)` or `first`).51     */52    fun discoverPeers(): Flow<DiscoveredPeer> = callbackFlow {53        val manager = ctx.getSystemService(NsdManager::class.java)54            ?: run { close(); return@callbackFlow }5556        // mDNS/NSD relies on Wi-Fi multicast, which Android drops by default to save57        // power. Hold a MulticastLock for the lifetime of discovery so service58        // announcements actually reach us (requires CHANGE_WIFI_MULTICAST_STATE).59        val multicastLock = (ctx.getSystemService(Context.WIFI_SERVICE) as? WifiManager)60            ?.createMulticastLock("peach-mdns")61            ?.apply { setReferenceCounted(false); runCatching { acquire() } }6263        val resolveListener = object : NsdManager.ResolveListener {64            override fun onServiceResolved(resolved: NsdServiceInfo) {65                parsePeer(resolved)?.let { trySend(it) }66            }67            override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {}68        }6970        val discoveryListener = object : NsdManager.DiscoveryListener {71            override fun onServiceFound(info: NsdServiceInfo) {72                if (info.serviceType.trimEnd('.') == PEACH_SERVICE_TYPE) {73                    runCatching { manager.resolveService(info, resolveListener) }74                }75            }76            override fun onServiceLost(info: NsdServiceInfo) {}77            override fun onDiscoveryStarted(serviceType: String) {}78            override fun onDiscoveryStopped(serviceType: String) {}79            override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { close() }80            override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}81        }8283        manager.discoverServices(PEACH_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)84        awaitClose {85            runCatching { manager.stopServiceDiscovery(discoveryListener) }86            multicastLock?.let { if (it.isHeld) runCatching { it.release() } }87        }88    }8990    /**91     * Dial [peer], run the Noise NK initiator handshake, exchange identities and92     * derive the SAS, then run one initiator sync round.93     *94     * Caller supplies the local static identity (Noise initiator does not have a95     * static key in NK, but our PAIR_IDENTITY + the sync layer still carry our96     * long-term identity) and a vault adapter factory.97     */98    suspend fun connectAndSync(99        peer: DiscoveredPeer,100        localIdentity: LocalChirpIdentity,101        vaultFactory: () -> ChirpSyncVaultAdapter?,102        connectTimeoutMs: Int = 15_000,103    ): PairingResult = withContext(Dispatchers.IO) {104        val socket = Socket()105        try {106            socket.connect(InetSocketAddress(peer.host, peer.port), connectTimeoutMs)107            socket.soTimeout = connectTimeoutMs108109            val transport = SocketFrameIO(110                input = socket.getInputStream(),111                output = socket.getOutputStream(),112                onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },113            )114115            val session = try {116                noiseNKInitiator(peer.remoteStaticPublicKey, transport::send, transport::receive)117            } catch (e: Throwable) {118                transport.close()119                return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)120            }121122            // PAIR_IDENTITY: initiator writes first, then reads (mirrors123            // ChirpPairing.runPairingInitiator).124            transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))125            val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))126127            val sas = SASDerivation.derive(session.handshakeHash)128            val device = pairIdentityToPairedDevice(theirIdentity)129            deviceStore.upsert(device)130131            // Encrypted vault sync — real initiator round.132            val vault = vaultFactory()133            if (vault != null) {134                val syncSession = chirpSessionOver(session, transport)135                val outcome = runChirpSyncInitiator(syncSession, vault)136                if (outcome.status == SyncOutcomeStatus.COMPLETED) {137                    deviceStore.updateLastSync(device.deviceId)138                }139            }140141            transport.close()142            PairingResult.Success(device, sas)143        } catch (e: SocketTimeoutException) {144            runCatching { socket.close() }145            PairingResult.Error(PairingError.TIMEOUT)146        } catch (e: Throwable) {147            runCatching { socket.close() }148            PairingResult.Error(PairingError.IO_ERROR)149        }150    }151152    /** Discover then dial the first peer whose deviceId matches [deviceId]. */153    suspend fun syncWithKnownDevice(154        deviceId: String,155        localIdentity: LocalChirpIdentity,156        vaultFactory: () -> ChirpSyncVaultAdapter?,157        discoveryTimeoutMs: Long = 5_000L,158    ): PairingResult {159        val peer = withTimeoutOrNull(discoveryTimeoutMs) {160            discoverPeers().first { it.deviceId == deviceId }161        } ?: return PairingResult.Error(PairingError.NOT_FOUND)162        return connectAndSync(peer, localIdentity, vaultFactory)163    }164165    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {166        val attrs = info.attributes ?: return null167        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null168        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null169        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null170        @Suppress("DEPRECATION")171        val host = info.host?.hostAddress ?: return null172        return DiscoveredPeer(173            deviceId = deviceId,174            deviceName = info.serviceName,175            host = host,176            port = info.port,177            remoteStaticPublicKey = hexToPubkey(pkHex),178        )179    }180181    private companion object {182        const val PEACH_SERVICE_TYPE = "_peach._tcp"183    }184}185186data class DiscoveredPeer(187    val deviceId: String,188    val deviceName: String,189    val host: String,190    val port: Int,191    /** Noise NK responder static public key, parsed from TXT "pk". */192    val remoteStaticPublicKey: ByteArray,193)194195sealed class PairingResult {196    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()197    data class Error(val reason: PairingError) : PairingResult()198}199200enum class PairingError {201    NOT_FOUND,202    HANDSHAKE_FAILED,203    VERSION_MISMATCH,204    ALREADY_PAIRED,205    TIMEOUT,206    IO_ERROR,207}