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 val syncSession = chirpSessionOver(session, transport)135 val outcome = runChirpSyncInitiator(syncSession, vault)136 if (outcome.status == SyncOutcomeStatus.COMPLETED) {137 deviceStore.updateLastSync(device.deviceId)138 }139 }140141 transport.close()142 PairingResult.Success(device, sas)143 } catch (e: SocketTimeoutException) {144 runCatching { socket.close() }145 PairingResult.Error(PairingError.TIMEOUT)146 } catch (e: Throwable) {147 runCatching { socket.close() }148 PairingResult.Error(PairingError.IO_ERROR)149 }150 }151152 153 suspend fun syncWithKnownDevice(154 deviceId: String,155 localIdentity: LocalChirpIdentity,156 vaultFactory: () -> ChirpSyncVaultAdapter?,157 discoveryTimeoutMs: Long = 5_000L,158 ): PairingResult {159 val peer = withTimeoutOrNull(discoveryTimeoutMs) {160 discoverPeers().first { it.deviceId == deviceId }161 } ?: return PairingResult.Error(PairingError.NOT_FOUND)162 return connectAndSync(peer, localIdentity, vaultFactory)163 }164165 private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {166 val attrs = info.attributes ?: return null167 val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null168 if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null169 val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null170 @Suppress("DEPRECATION")171 val host = info.host?.hostAddress ?: return null172 return DiscoveredPeer(173 deviceId = deviceId,174 deviceName = info.serviceName,175 host = host,176 port = info.port,177 remoteStaticPublicKey = hexToPubkey(pkHex),178 )179 }180181 private companion object {182 const val PEACH_SERVICE_TYPE = "_peach._tcp"183 }184}185186data class DiscoveredPeer(187 val deviceId: String,188 val deviceName: String,189 val host: String,190 val port: Int,191 192 val remoteStaticPublicKey: ByteArray,193)194195sealed class PairingResult {196 data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()197 data class Error(val reason: PairingError) : PairingResult()198}199200enum class PairingError {201 NOT_FOUND,202 HANDSHAKE_FAILED,203 VERSION_MISMATCH,204 ALREADY_PAIRED,205 TIMEOUT,206 IO_ERROR,207}