PairingDiscoveryClient.kt

13.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 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        var noiseSession: NoiseNKSession? = null110        try {111            socket.connect(InetSocketAddress(peer.host, peer.port), connectTimeoutMs)112            socket.soTimeout = connectTimeoutMs113114            val transport = SocketFrameIO(115                input = socket.getInputStream(),116                output = socket.getOutputStream(),117                onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },118            )119120            val localPublicKey = hexToPubkey(localIdentity.longTermPublicKeyHex)121            transport.send(122                JSONObject()123                    .put("type", "PEACH_RECONNECT")124                    .put("rendezvous_id", rendezvousId)125                    .toString()126                    .toByteArray(Charsets.UTF_8),127            )128            val session = try {129                noiseKKInitiator(130                    localStaticPrivateKey,131                    localPublicKey,132                    peer.remoteStaticPublicKey,133                    transport::send,134                    transport::receive,135                )136            } catch (e: Throwable) {137                transport.close()138                return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)139            }140            noiseSession = session141142            // PAIR_IDENTITY: initiator writes first, then reads (mirrors143            // ChirpPairing.runPairingInitiator).144            transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))145            val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))146            require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }147            require(148                theirIdentity.longTermPublicKeyHex.equals(149                    pubkeyToHex(peer.remoteStaticPublicKey),150                    ignoreCase = true,151                ),152            ) { "peer identity does not match authenticated KK key" }153154            val sas = SASDerivation.derive(session.handshakeHash)155            val incoming = pairIdentityToPairedDevice(theirIdentity)156            val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())157                ?: deviceStore.get(incoming.deviceId)158            val device = incoming.copy(159                rendezvousSecretHex = existing?.rendezvousSecretHex,160                relayUrl = existing?.relayUrl ?: incoming.relayUrl,161            )162            deviceStore.upsert(device)163164            // Encrypted vault sync — real initiator round.165            val vault = vaultFactory()166            if (vault != null) {167                vault.bindAuthenticatedPeer(device)168                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)169                val outcome = runChirpSyncInitiator(syncSession, vault)170                if (outcome.status == SyncOutcomeStatus.COMPLETED) {171                    deviceStore.updateLastSync(device.deviceId)172                }173            }174175            transport.close()176            PairingResult.Success(device, sas)177        } catch (e: SocketTimeoutException) {178            runCatching { socket.close() }179            PairingResult.Error(PairingError.TIMEOUT)180        } catch (e: Throwable) {181            runCatching { socket.close() }182            PairingResult.Error(PairingError.IO_ERROR)183        } finally {184            noiseSession?.close()185        }186    }187188    /**189     * Dial a known peer: the cached address first, discovery only as a fallback.190     *191     * [cached] is the peer's last-known endpoint, refreshed by the listener on192     * every inbound reconnect. Trying it first is the "silent reconnect" the193     * sync design asks for (§6) — this used to go straight to mDNS and report194     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at195     * its cached address failed to sync whenever discovery was unavailable,196     * which on a home network is often.197     *198     * The cached attempt gets a short timeout: a stale address is the normal199     * case after a network change, and it must not delay the fallback.200     */201    suspend fun syncWithKnownDevice(202        deviceId: String,203        localIdentity: LocalChirpIdentity,204        localStaticPrivateKey: ByteArray,205        vaultFactory: () -> ChirpSyncVaultAdapter?,206        discoveryTimeoutMs: Long = 5_000L,207        cached: DiscoveredPeer? = null,208        cachedTimeoutMs: Int = 2_000,209    ): PairingResult {210        val paired = deviceStore.get(deviceId)211            ?: return PairingResult.Error(PairingError.NOT_FOUND)212        val secret = paired.rendezvousSecretHex213            ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }214            ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)215        val rendezvousIds = Rendezvous.dialingIds(216            secret,217            System.currentTimeMillis() / 1000L,218        )219        if (cached != null) {220            val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)221            for (rendezvousId in rendezvousIds) {222                val viaCache = connectAndSync(223                    pinnedCached,224                    localIdentity,225                    localStaticPrivateKey,226                    rendezvousId,227                    vaultFactory,228                    connectTimeoutMs = cachedTimeoutMs,229                )230                if (viaCache is PairingResult.Success) return viaCache231            }232        }233        val discovered = withTimeoutOrNull(discoveryTimeoutMs) {234            discoverPeers().first { it.deviceId == deviceId }235        } ?: return PairingResult.Error(PairingError.NOT_FOUND)236        val peer = pinDiscoveredPeerToPairedState(paired, discovered)237        var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)238        for (rendezvousId in rendezvousIds) {239            last = connectAndSync(240                peer,241                localIdentity,242                localStaticPrivateKey,243                rendezvousId,244                vaultFactory,245            )246            if (last is PairingResult.Success) return last247        }248        return last249    }250251    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {252        val attrs = info.attributes ?: return null253        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null254        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null255        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null256        @Suppress("DEPRECATION")257        val host = info.host?.hostAddress ?: return null258        return DiscoveredPeer(259            deviceId = deviceId,260            deviceName = info.serviceName,261            host = host,262            port = info.port,263            remoteStaticPublicKey = hexToPubkey(pkHex),264        )265    }266267    private companion object {268        const val PEACH_SERVICE_TYPE = "_peach._tcp"269    }270}271272data class DiscoveredPeer(273    val deviceId: String,274    val deviceName: String,275    val host: String,276    val port: Int,277    /** Paired responder static public key, parsed from TXT "pk" and pinned in KK. */278    val remoteStaticPublicKey: ByteArray,279)280281/**282 * Discovery supplies reachability only. KK's responder key always comes from283 * paired local state; a spoofed mDNS TXT record can redirect or deny service,284 * but cannot choose the key the initiator authenticates.285 */286internal fun pinDiscoveredPeerToPairedState(287    paired: PairedDevice,288    discovered: DiscoveredPeer,289): DiscoveredPeer {290    require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }291    val expectedKey = paired.longTermPublicKeyHex292        ?: throw IllegalArgumentException("paired peer missing static key")293    return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))294}295296sealed class PairingResult {297    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()298    data class Error(val reason: PairingError) : PairingResult()299}300301enum class PairingError {302    NOT_FOUND,303    HANDSHAKE_FAILED,304    VERSION_MISMATCH,305    ALREADY_PAIRED,306    TIMEOUT,307    IO_ERROR,308}