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.Singleton2021222324252627282930313233343536373839404142@Singleton43class PairingDiscoveryClient @Inject constructor(44 @ApplicationContext private val ctx: Context,45 private val deviceStore: PairedDeviceStore,46) {47 4849505152 fun discoverPeers(): Flow<DiscoveredPeer> = callbackFlow {53 val manager = ctx.getSystemService(NsdManager::class.java)54 ?: run { close(); return@callbackFlow }5556 57 58 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 9192939495969798 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 123 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 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 160161162163164165166167168169170171172 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 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}