PairingDiscoveryClient.kt

9.7 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    /**153     * Dial a known peer: the cached address first, discovery only as a fallback.154     *155     * [cached] is the peer's last-known endpoint, refreshed by the listener on156     * every inbound reconnect. Trying it first is the "silent reconnect" the157     * sync design asks for (§6) — this used to go straight to mDNS and report158     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at159     * its cached address failed to sync whenever discovery was unavailable,160     * which on a home network is often.161     *162     * The cached attempt gets a short timeout: a stale address is the normal163     * case after a network change, and it must not delay the fallback.164     */165    suspend fun syncWithKnownDevice(166        deviceId: String,167        localIdentity: LocalChirpIdentity,168        vaultFactory: () -> ChirpSyncVaultAdapter?,169        discoveryTimeoutMs: Long = 5_000L,170        cached: DiscoveredPeer? = null,171        cachedTimeoutMs: Int = 2_000,172    ): PairingResult {173        if (cached != null) {174            val viaCache = connectAndSync(cached, localIdentity, vaultFactory, connectTimeoutMs = cachedTimeoutMs)175            if (viaCache is PairingResult.Success) return viaCache176        }177        val peer = withTimeoutOrNull(discoveryTimeoutMs) {178            discoverPeers().first { it.deviceId == deviceId }179        } ?: return PairingResult.Error(PairingError.NOT_FOUND)180        return connectAndSync(peer, localIdentity, vaultFactory)181    }182183    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {184        val attrs = info.attributes ?: return null185        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null186        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null187        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null188        @Suppress("DEPRECATION")189        val host = info.host?.hostAddress ?: return null190        return DiscoveredPeer(191            deviceId = deviceId,192            deviceName = info.serviceName,193            host = host,194            port = info.port,195            remoteStaticPublicKey = hexToPubkey(pkHex),196        )197    }198199    private companion object {200        const val PEACH_SERVICE_TYPE = "_peach._tcp"201    }202}203204data class DiscoveredPeer(205    val deviceId: String,206    val deviceName: String,207    val host: String,208    val port: Int,209    /** Noise NK responder static public key, parsed from TXT "pk". */210    val remoteStaticPublicKey: ByteArray,211)212213sealed class PairingResult {214    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()215    data class Error(val reason: PairingError) : PairingResult()216}217218enum class PairingError {219    NOT_FOUND,220    HANDSHAKE_FAILED,221    VERSION_MISMATCH,222    ALREADY_PAIRED,223    TIMEOUT,224    IO_ERROR,225}