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                vault.bindAuthenticatedPeer(device)135                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)136                val outcome = runChirpSyncInitiator(syncSession, vault)137                if (outcome.status == SyncOutcomeStatus.COMPLETED) {138                    deviceStore.updateLastSync(device.deviceId)139                }140            }141142            transport.close()143            PairingResult.Success(device, sas)144        } catch (e: SocketTimeoutException) {145            runCatching { socket.close() }146            PairingResult.Error(PairingError.TIMEOUT)147        } catch (e: Throwable) {148            runCatching { socket.close() }149            PairingResult.Error(PairingError.IO_ERROR)150        }151    }152153    /**154     * Dial a known peer: the cached address first, discovery only as a fallback.155     *156     * [cached] is the peer's last-known endpoint, refreshed by the listener on157     * every inbound reconnect. Trying it first is the "silent reconnect" the158     * sync design asks for (§6) — this used to go straight to mDNS and report159     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at160     * its cached address failed to sync whenever discovery was unavailable,161     * which on a home network is often.162     *163     * The cached attempt gets a short timeout: a stale address is the normal164     * case after a network change, and it must not delay the fallback.165     */166    suspend fun syncWithKnownDevice(167        deviceId: String,168        localIdentity: LocalChirpIdentity,169        vaultFactory: () -> ChirpSyncVaultAdapter?,170        discoveryTimeoutMs: Long = 5_000L,171        cached: DiscoveredPeer? = null,172        cachedTimeoutMs: Int = 2_000,173    ): PairingResult {174        if (cached != null) {175            val viaCache = connectAndSync(cached, localIdentity, vaultFactory, connectTimeoutMs = cachedTimeoutMs)176            if (viaCache is PairingResult.Success) return viaCache177        }178        val peer = withTimeoutOrNull(discoveryTimeoutMs) {179            discoverPeers().first { it.deviceId == deviceId }180        } ?: return PairingResult.Error(PairingError.NOT_FOUND)181        return connectAndSync(peer, localIdentity, vaultFactory)182    }183184    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {185        val attrs = info.attributes ?: return null186        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null187        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null188        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null189        @Suppress("DEPRECATION")190        val host = info.host?.hostAddress ?: return null191        return DiscoveredPeer(192            deviceId = deviceId,193            deviceName = info.serviceName,194            host = host,195            port = info.port,196            remoteStaticPublicKey = hexToPubkey(pkHex),197        )198    }199200    private companion object {201        const val PEACH_SERVICE_TYPE = "_peach._tcp"202    }203}204205data class DiscoveredPeer(206    val deviceId: String,207    val deviceName: String,208    val host: String,209    val port: Int,210    /** Noise NK responder static public key, parsed from TXT "pk". */211    val remoteStaticPublicKey: ByteArray,212)213214sealed class PairingResult {215    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()216    data class Error(val reason: PairingError) : PairingResult()217}218219enum class PairingError {220    NOT_FOUND,221    HANDSHAKE_FAILED,222    VERSION_MISMATCH,223    ALREADY_PAIRED,224    TIMEOUT,225    IO_ERROR,226}