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 device = pairIdentityToPairedDevice(theirIdentity)129 deviceStore.upsert(device)130131 132 val vault = vaultFactory()133 if (vault != null) {134 vault.bindAuthenticatedPeer(device)135 val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)136 val outcome = runChirpSyncInitiator(syncSession, vault)137 if (outcome.status == SyncOutcomeStatus.COMPLETED) {138 deviceStore.updateLastSync(device.deviceId)139 }140 }141142 transport.close()143 PairingResult.Success(device, sas)144 } catch (e: SocketTimeoutException) {145 runCatching { socket.close() }146 PairingResult.Error(PairingError.TIMEOUT)147 } catch (e: Throwable) {148 runCatching { socket.close() }149 PairingResult.Error(PairingError.IO_ERROR)150 }151 }152153 154155156157158159160161162163164165166 suspend fun syncWithKnownDevice(167 deviceId: String,168 localIdentity: LocalChirpIdentity,169 vaultFactory: () -> ChirpSyncVaultAdapter?,170 discoveryTimeoutMs: Long = 5_000L,171 cached: DiscoveredPeer? = null,172 cachedTimeoutMs: Int = 2_000,173 ): PairingResult {174 if (cached != null) {175 val viaCache = connectAndSync(cached, localIdentity, vaultFactory, connectTimeoutMs = cachedTimeoutMs)176 if (viaCache is PairingResult.Success) return viaCache177 }178 val peer = withTimeoutOrNull(discoveryTimeoutMs) {179 discoverPeers().first { it.deviceId == deviceId }180 } ?: return PairingResult.Error(PairingError.NOT_FOUND)181 return connectAndSync(peer, localIdentity, vaultFactory)182 }183184 private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {185 val attrs = info.attributes ?: return null186 val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null187 if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null188 val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null189 @Suppress("DEPRECATION")190 val host = info.host?.hostAddress ?: return null191 return DiscoveredPeer(192 deviceId = deviceId,193 deviceName = info.serviceName,194 host = host,195 port = info.port,196 remoteStaticPublicKey = hexToPubkey(pkHex),197 )198 }199200 private companion object {201 const val PEACH_SERVICE_TYPE = "_peach._tcp"202 }203}204205data class DiscoveredPeer(206 val deviceId: String,207 val deviceName: String,208 val host: String,209 val port: Int,210 211 val remoteStaticPublicKey: ByteArray,212)213214sealed class PairingResult {215 data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()216 data class Error(val reason: PairingError) : PairingResult()217}218219enum class PairingError {220 NOT_FOUND,221 HANDSHAKE_FAILED,222 VERSION_MISMATCH,223 ALREADY_PAIRED,224 TIMEOUT,225 IO_ERROR,226}