PairingDiscoveryClient.kt

10.1 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 incoming = pairIdentityToPairedDevice(theirIdentity)129            val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())130                ?: deviceStore.get(incoming.deviceId)131            val device = incoming.copy(132                rendezvousSecretHex = existing?.rendezvousSecretHex,133                relayUrl = existing?.relayUrl ?: incoming.relayUrl,134            )135            deviceStore.upsert(device)136137            // Encrypted vault sync — real initiator round.138            val vault = vaultFactory()139            if (vault != null) {140                vault.bindAuthenticatedPeer(device)141                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)142                val outcome = runChirpSyncInitiator(syncSession, vault)143                if (outcome.status == SyncOutcomeStatus.COMPLETED) {144                    deviceStore.updateLastSync(device.deviceId)145                }146            }147148            transport.close()149            PairingResult.Success(device, sas)150        } catch (e: SocketTimeoutException) {151            runCatching { socket.close() }152            PairingResult.Error(PairingError.TIMEOUT)153        } catch (e: Throwable) {154            runCatching { socket.close() }155            PairingResult.Error(PairingError.IO_ERROR)156        }157    }158159    /**160     * Dial a known peer: the cached address first, discovery only as a fallback.161     *162     * [cached] is the peer's last-known endpoint, refreshed by the listener on163     * every inbound reconnect. Trying it first is the "silent reconnect" the164     * sync design asks for (§6) — this used to go straight to mDNS and report165     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at166     * its cached address failed to sync whenever discovery was unavailable,167     * which on a home network is often.168     *169     * The cached attempt gets a short timeout: a stale address is the normal170     * case after a network change, and it must not delay the fallback.171     */172    suspend fun syncWithKnownDevice(173        deviceId: String,174        localIdentity: LocalChirpIdentity,175        vaultFactory: () -> ChirpSyncVaultAdapter?,176        discoveryTimeoutMs: Long = 5_000L,177        cached: DiscoveredPeer? = null,178        cachedTimeoutMs: Int = 2_000,179    ): PairingResult {180        if (cached != null) {181            val viaCache = connectAndSync(cached, localIdentity, vaultFactory, connectTimeoutMs = cachedTimeoutMs)182            if (viaCache is PairingResult.Success) return viaCache183        }184        val peer = withTimeoutOrNull(discoveryTimeoutMs) {185            discoverPeers().first { it.deviceId == deviceId }186        } ?: return PairingResult.Error(PairingError.NOT_FOUND)187        return connectAndSync(peer, localIdentity, vaultFactory)188    }189190    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {191        val attrs = info.attributes ?: return null192        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null193        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null194        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null195        @Suppress("DEPRECATION")196        val host = info.host?.hostAddress ?: return null197        return DiscoveredPeer(198            deviceId = deviceId,199            deviceName = info.serviceName,200            host = host,201            port = info.port,202            remoteStaticPublicKey = hexToPubkey(pkHex),203        )204    }205206    private companion object {207        const val PEACH_SERVICE_TYPE = "_peach._tcp"208    }209}210211data class DiscoveredPeer(212    val deviceId: String,213    val deviceName: String,214    val host: String,215    val port: Int,216    /** Noise NK responder static public key, parsed from TXT "pk". */217    val remoteStaticPublicKey: ByteArray,218)219220sealed class PairingResult {221    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()222    data class Error(val reason: PairingError) : PairingResult()223}224225enum class PairingError {226    NOT_FOUND,227    HANDSHAKE_FAILED,228    VERSION_MISMATCH,229    ALREADY_PAIRED,230    TIMEOUT,231    IO_ERROR,232}