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(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))145            val theirIdentity = parsePairIdentityFrame(146                SyncPadding.unpad(session.decrypt(transport.receive())),147            )148            require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }149            require(150                CryptoCompare.hex(151                    theirIdentity.longTermPublicKeyHex,152                    pubkeyToHex(peer.remoteStaticPublicKey),153                ),154            ) { "peer identity does not match authenticated KK key" }155156            val sas = SASDerivation.derive(session.handshakeHash)157            val incoming = pairIdentityToPairedDevice(theirIdentity)158            val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())159                ?: deviceStore.get(incoming.deviceId)160            val device = incoming.copy(161                rendezvousSecretHex = existing?.rendezvousSecretHex,162                relayUrl = existing?.relayUrl ?: incoming.relayUrl,163            )164            deviceStore.upsert(device)165166            // Encrypted vault sync — real initiator round.167            val vault = vaultFactory()168            if (vault != null) {169                vault.bindAuthenticatedPeer(device)170                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)171                val outcome = runChirpSyncInitiator(syncSession, vault)172                if (outcome.status == SyncOutcomeStatus.COMPLETED) {173                    deviceStore.updateLastSync(device.deviceId)174                }175            }176177            transport.close()178            PairingResult.Success(device, sas)179        } catch (e: SocketTimeoutException) {180            runCatching { socket.close() }181            PairingResult.Error(PairingError.TIMEOUT)182        } catch (e: Throwable) {183            runCatching { socket.close() }184            PairingResult.Error(PairingError.IO_ERROR)185        } finally {186            noiseSession?.close()187        }188    }189190    /**191     * Dial a known peer: the cached address first, discovery only as a fallback.192     *193     * [cached] is the peer's last-known endpoint, refreshed by the listener on194     * every inbound reconnect. Trying it first is the "silent reconnect" the195     * sync design asks for (§6) — this used to go straight to mDNS and report196     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at197     * its cached address failed to sync whenever discovery was unavailable,198     * which on a home network is often.199     *200     * The cached attempt gets a short timeout: a stale address is the normal201     * case after a network change, and it must not delay the fallback.202     */203    suspend fun syncWithKnownDevice(204        deviceId: String,205        localIdentity: LocalChirpIdentity,206        localStaticPrivateKey: ByteArray,207        vaultFactory: () -> ChirpSyncVaultAdapter?,208        discoveryTimeoutMs: Long = 5_000L,209        cached: DiscoveredPeer? = null,210        cachedTimeoutMs: Int = 2_000,211    ): PairingResult {212        val paired = deviceStore.get(deviceId)213            ?: return PairingResult.Error(PairingError.NOT_FOUND)214        val secret = paired.rendezvousSecretHex215            ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }216            ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)217        val rendezvousIds = Rendezvous.dialingIds(218            secret,219            System.currentTimeMillis() / 1000L,220        )221        if (cached != null) {222            val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)223            for (rendezvousId in rendezvousIds) {224                val viaCache = connectAndSync(225                    pinnedCached,226                    localIdentity,227                    localStaticPrivateKey,228                    rendezvousId,229                    vaultFactory,230                    connectTimeoutMs = cachedTimeoutMs,231                )232                if (viaCache is PairingResult.Success) return viaCache233            }234        }235        val discovered = withTimeoutOrNull(discoveryTimeoutMs) {236            discoverPeers().first { it.deviceId == deviceId }237        } ?: return PairingResult.Error(PairingError.NOT_FOUND)238        val peer = pinDiscoveredPeerToPairedState(paired, discovered)239        var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)240        for (rendezvousId in rendezvousIds) {241            last = connectAndSync(242                peer,243                localIdentity,244                localStaticPrivateKey,245                rendezvousId,246                vaultFactory,247            )248            if (last is PairingResult.Success) return last249        }250        return last251    }252253    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {254        val attrs = info.attributes ?: return null255        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null256        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null257        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null258        @Suppress("DEPRECATION")259        val host = info.host?.hostAddress ?: return null260        return DiscoveredPeer(261            deviceId = deviceId,262            deviceName = info.serviceName,263            host = host,264            port = info.port,265            remoteStaticPublicKey = hexToPubkey(pkHex),266        )267    }268269    private companion object {270        const val PEACH_SERVICE_TYPE = "_peach._tcp"271    }272}273274data class DiscoveredPeer(275    val deviceId: String,276    val deviceName: String,277    val host: String,278    val port: Int,279    /** Paired responder static public key, parsed from TXT "pk" and pinned in KK. */280    val remoteStaticPublicKey: ByteArray,281)282283/**284 * Discovery supplies reachability only. KK's responder key always comes from285 * paired local state; a spoofed mDNS TXT record can redirect or deny service,286 * but cannot choose the key the initiator authenticates.287 */288internal fun pinDiscoveredPeerToPairedState(289    paired: PairedDevice,290    discovered: DiscoveredPeer,291): DiscoveredPeer {292    require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }293    val expectedKey = paired.longTermPublicKeyHex294        ?: throw IllegalArgumentException("paired peer missing static key")295    return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))296}297298sealed class PairingResult {299    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()300    data class Error(val reason: PairingError) : PairingResult()301}302303enum class PairingError {304    NOT_FOUND,305    HANDSHAKE_FAILED,306    VERSION_MISMATCH,307    ALREADY_PAIRED,308    TIMEOUT,309    IO_ERROR,310}