PairingDiscoveryClient.kt

12.9 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 org.json.JSONObject16import java.net.InetSocketAddress17import java.net.Socket18import java.net.SocketTimeoutException19import javax.inject.Inject20import javax.inject.Singleton2122/**23 * C7 TCP + mDNS reconnect CLIENT (initiator side). Discovers `_peach._tcp`24 * peers, dials one, and runs the REAL initiator pipeline:25 *   1. An opaque, rotating PEACH_RECONNECT capability lets the responder choose26 *      an already-paired local record, then Noise KK authenticates both stored27 *      static keys.28 *   2. PAIR_IDENTITY exchange (initiator writes first, then reads), matching29 *      ChirpPairing.runPairingInitiator ordering.30 *   3. SAS via the REAL [SASDerivation.derive] over the Noise handshake hash.31 *   4. Encrypted vault sync via the REAL [runChirpSyncInitiator] over a32 *      [ChirpSyncSession].33 *34 * ── Reconciliation with the brief ──────────────────────────────────────────35 *  - Reconnect is strictly KK: both local static keys are inputs and a failure36 *    never negotiates or retries NK. The returned [NoiseNKSession] interface is37 *    transport-neutral legacy naming, not the active handshake pattern.38 *  - `syncAdapter.buildRequest/applyResponse` do NOT exist. Real round driver is39 *    [runChirpSyncInitiator] against a [ChirpSyncVaultAdapter].40 *  - `peer.serviceInfo.host/.port`: on API < 34, [NsdServiceInfo.getHost] is41 *    deprecated; on the supported floor it still returns the resolved42 *    InetAddress, which we use for the dial. The resolved port is authoritative.43 */44@Singleton45class PairingDiscoveryClient @Inject constructor(46    @ApplicationContext private val ctx: Context,47    private val deviceStore: PairedDeviceStore,48) {49    /**50     * Discover nearby Peach instances via mDNS. Each found service is resolved,51     * its TXT records parsed into a [DiscoveredPeer], and emitted. The flow runs52     * until the collector cancels (e.g. via kotlinx `.timeout(...)` or `first`).53     */54    fun discoverPeers(): Flow<DiscoveredPeer> = callbackFlow {55        val manager = ctx.getSystemService(NsdManager::class.java)56            ?: run { close(); return@callbackFlow }5758        // mDNS/NSD relies on Wi-Fi multicast, which Android drops by default to save59        // power. Hold a MulticastLock for the lifetime of discovery so service60        // announcements actually reach us (requires CHANGE_WIFI_MULTICAST_STATE).61        val multicastLock = (ctx.getSystemService(Context.WIFI_SERVICE) as? WifiManager)62            ?.createMulticastLock("peach-mdns")63            ?.apply { setReferenceCounted(false); runCatching { acquire() } }6465        val resolveListener = object : NsdManager.ResolveListener {66            override fun onServiceResolved(resolved: NsdServiceInfo) {67                parsePeer(resolved)?.let { trySend(it) }68            }69            override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {}70        }7172        val discoveryListener = object : NsdManager.DiscoveryListener {73            override fun onServiceFound(info: NsdServiceInfo) {74                if (info.serviceType.trimEnd('.') == PEACH_SERVICE_TYPE) {75                    runCatching { manager.resolveService(info, resolveListener) }76                }77            }78            override fun onServiceLost(info: NsdServiceInfo) {}79            override fun onDiscoveryStarted(serviceType: String) {}80            override fun onDiscoveryStopped(serviceType: String) {}81            override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { close() }82            override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}83        }8485        manager.discoverServices(PEACH_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)86        awaitClose {87            runCatching { manager.stopServiceDiscovery(discoveryListener) }88            multicastLock?.let { if (it.isHeld) runCatching { it.release() } }89        }90    }9192    /**93     * Dial [peer], run the Noise KK initiator handshake, exchange identities and94     * derive the SAS, then run one initiator sync round.95     *96     * Caller supplies the local static identity and private key required by KK,97     * plus a vault adapter factory. The pre-handshake selector is an opaque98     * rendezvous capability; it never discloses either stored public key.99     */100    private suspend fun connectAndSync(101        peer: DiscoveredPeer,102        localIdentity: LocalChirpIdentity,103        localStaticPrivateKey: ByteArray,104        rendezvousId: String,105        vaultFactory: () -> ChirpSyncVaultAdapter?,106        connectTimeoutMs: Int = 15_000,107    ): PairingResult = withContext(Dispatchers.IO) {108        val socket = Socket()109        try {110            socket.connect(InetSocketAddress(peer.host, peer.port), connectTimeoutMs)111            socket.soTimeout = connectTimeoutMs112113            val transport = SocketFrameIO(114                input = socket.getInputStream(),115                output = socket.getOutputStream(),116                onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },117            )118119            val localPublicKey = hexToPubkey(localIdentity.longTermPublicKeyHex)120            transport.send(121                JSONObject()122                    .put("type", "PEACH_RECONNECT")123                    .put("rendezvous_id", rendezvousId)124                    .toString()125                    .toByteArray(Charsets.UTF_8),126            )127            val session = try {128                noiseKKInitiator(129                    localStaticPrivateKey,130                    localPublicKey,131                    peer.remoteStaticPublicKey,132                    transport::send,133                    transport::receive,134                )135            } catch (e: Throwable) {136                transport.close()137                return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)138            }139140            // PAIR_IDENTITY: initiator writes first, then reads (mirrors141            // ChirpPairing.runPairingInitiator).142            transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))143            val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))144            require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }145            require(146                theirIdentity.longTermPublicKeyHex.equals(147                    pubkeyToHex(peer.remoteStaticPublicKey),148                    ignoreCase = true,149                ),150            ) { "peer identity does not match authenticated KK key" }151152            val sas = SASDerivation.derive(session.handshakeHash)153            val incoming = pairIdentityToPairedDevice(theirIdentity)154            val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())155                ?: deviceStore.get(incoming.deviceId)156            val device = incoming.copy(157                rendezvousSecretHex = existing?.rendezvousSecretHex,158                relayUrl = existing?.relayUrl ?: incoming.relayUrl,159            )160            deviceStore.upsert(device)161162            // Encrypted vault sync — real initiator round.163            val vault = vaultFactory()164            if (vault != null) {165                vault.bindAuthenticatedPeer(device)166                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)167                val outcome = runChirpSyncInitiator(syncSession, vault)168                if (outcome.status == SyncOutcomeStatus.COMPLETED) {169                    deviceStore.updateLastSync(device.deviceId)170                }171            }172173            transport.close()174            PairingResult.Success(device, sas)175        } catch (e: SocketTimeoutException) {176            runCatching { socket.close() }177            PairingResult.Error(PairingError.TIMEOUT)178        } catch (e: Throwable) {179            runCatching { socket.close() }180            PairingResult.Error(PairingError.IO_ERROR)181        }182    }183184    /**185     * Dial a known peer: the cached address first, discovery only as a fallback.186     *187     * [cached] is the peer's last-known endpoint, refreshed by the listener on188     * every inbound reconnect. Trying it first is the "silent reconnect" the189     * sync design asks for (§6) — this used to go straight to mDNS and report190     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at191     * its cached address failed to sync whenever discovery was unavailable,192     * which on a home network is often.193     *194     * The cached attempt gets a short timeout: a stale address is the normal195     * case after a network change, and it must not delay the fallback.196     */197    suspend fun syncWithKnownDevice(198        deviceId: String,199        localIdentity: LocalChirpIdentity,200        localStaticPrivateKey: ByteArray,201        vaultFactory: () -> ChirpSyncVaultAdapter?,202        discoveryTimeoutMs: Long = 5_000L,203        cached: DiscoveredPeer? = null,204        cachedTimeoutMs: Int = 2_000,205    ): PairingResult {206        val paired = deviceStore.get(deviceId)207            ?: return PairingResult.Error(PairingError.NOT_FOUND)208        val secret = paired.rendezvousSecretHex209            ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }210            ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)211        val rendezvousIds = Rendezvous.dialingIds(212            secret,213            System.currentTimeMillis() / 1000L,214        )215        if (cached != null) {216            val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)217            for (rendezvousId in rendezvousIds) {218                val viaCache = connectAndSync(219                    pinnedCached,220                    localIdentity,221                    localStaticPrivateKey,222                    rendezvousId,223                    vaultFactory,224                    connectTimeoutMs = cachedTimeoutMs,225                )226                if (viaCache is PairingResult.Success) return viaCache227            }228        }229        val discovered = withTimeoutOrNull(discoveryTimeoutMs) {230            discoverPeers().first { it.deviceId == deviceId }231        } ?: return PairingResult.Error(PairingError.NOT_FOUND)232        val peer = pinDiscoveredPeerToPairedState(paired, discovered)233        var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)234        for (rendezvousId in rendezvousIds) {235            last = connectAndSync(236                peer,237                localIdentity,238                localStaticPrivateKey,239                rendezvousId,240                vaultFactory,241            )242            if (last is PairingResult.Success) return last243        }244        return last245    }246247    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {248        val attrs = info.attributes ?: return null249        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null250        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null251        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null252        @Suppress("DEPRECATION")253        val host = info.host?.hostAddress ?: return null254        return DiscoveredPeer(255            deviceId = deviceId,256            deviceName = info.serviceName,257            host = host,258            port = info.port,259            remoteStaticPublicKey = hexToPubkey(pkHex),260        )261    }262263    private companion object {264        const val PEACH_SERVICE_TYPE = "_peach._tcp"265    }266}267268data class DiscoveredPeer(269    val deviceId: String,270    val deviceName: String,271    val host: String,272    val port: Int,273    /** Paired responder static public key, parsed from TXT "pk" and pinned in KK. */274    val remoteStaticPublicKey: ByteArray,275)276277/**278 * Discovery supplies reachability only. KK's responder key always comes from279 * paired local state; a spoofed mDNS TXT record can redirect or deny service,280 * but cannot choose the key the initiator authenticates.281 */282internal fun pinDiscoveredPeerToPairedState(283    paired: PairedDevice,284    discovered: DiscoveredPeer,285): DiscoveredPeer {286    require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }287    val expectedKey = paired.longTermPublicKeyHex288        ?: throw IllegalArgumentException("paired peer missing static key")289    return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))290}291292sealed class PairingResult {293    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()294    data class Error(val reason: PairingError) : PairingResult()295}296297enum class PairingError {298    NOT_FOUND,299    HANDSHAKE_FAILED,300    VERSION_MISMATCH,301    ALREADY_PAIRED,302    TIMEOUT,303    IO_ERROR,304}