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.Singleton212223242526272829303132333435363738394041424344@Singleton45class PairingDiscoveryClient @Inject constructor(46 @ApplicationContext private val ctx: Context,47 private val deviceStore: PairedDeviceStore,48) {49 5051525354 fun discoverPeers(): Flow<DiscoveredPeer> = callbackFlow {55 val manager = ctx.getSystemService(NsdManager::class.java)56 ?: run { close(); return@callbackFlow }5758 59 60 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 93949596979899100 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 try {110 socket.connect(InetSocketAddress(peer.host, peer.port), connectTimeoutMs)111 socket.soTimeout = connectTimeoutMs112113 val transport = SocketFrameIO(114 input = socket.getInputStream(),115 output = socket.getOutputStream(),116 onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },117 )118119 val localPublicKey = hexToPubkey(localIdentity.longTermPublicKeyHex)120 transport.send(121 JSONObject()122 .put("type", "PEACH_RECONNECT")123 .put("rendezvous_id", rendezvousId)124 .toString()125 .toByteArray(Charsets.UTF_8),126 )127 val session = try {128 noiseKKInitiator(129 localStaticPrivateKey,130 localPublicKey,131 peer.remoteStaticPublicKey,132 transport::send,133 transport::receive,134 )135 } catch (e: Throwable) {136 transport.close()137 return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)138 }139140 141 142 transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))143 val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))144 require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }145 require(146 theirIdentity.longTermPublicKeyHex.equals(147 pubkeyToHex(peer.remoteStaticPublicKey),148 ignoreCase = true,149 ),150 ) { "peer identity does not match authenticated KK key" }151152 val sas = SASDerivation.derive(session.handshakeHash)153 val incoming = pairIdentityToPairedDevice(theirIdentity)154 val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())155 ?: deviceStore.get(incoming.deviceId)156 val device = incoming.copy(157 rendezvousSecretHex = existing?.rendezvousSecretHex,158 relayUrl = existing?.relayUrl ?: incoming.relayUrl,159 )160 deviceStore.upsert(device)161162 163 val vault = vaultFactory()164 if (vault != null) {165 vault.bindAuthenticatedPeer(device)166 val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)167 val outcome = runChirpSyncInitiator(syncSession, vault)168 if (outcome.status == SyncOutcomeStatus.COMPLETED) {169 deviceStore.updateLastSync(device.deviceId)170 }171 }172173 transport.close()174 PairingResult.Success(device, sas)175 } catch (e: SocketTimeoutException) {176 runCatching { socket.close() }177 PairingResult.Error(PairingError.TIMEOUT)178 } catch (e: Throwable) {179 runCatching { socket.close() }180 PairingResult.Error(PairingError.IO_ERROR)181 }182 }183184 185186187188189190191192193194195196197 suspend fun syncWithKnownDevice(198 deviceId: String,199 localIdentity: LocalChirpIdentity,200 localStaticPrivateKey: ByteArray,201 vaultFactory: () -> ChirpSyncVaultAdapter?,202 discoveryTimeoutMs: Long = 5_000L,203 cached: DiscoveredPeer? = null,204 cachedTimeoutMs: Int = 2_000,205 ): PairingResult {206 val paired = deviceStore.get(deviceId)207 ?: return PairingResult.Error(PairingError.NOT_FOUND)208 val secret = paired.rendezvousSecretHex209 ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }210 ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)211 val rendezvousIds = Rendezvous.dialingIds(212 secret,213 System.currentTimeMillis() / 1000L,214 )215 if (cached != null) {216 val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)217 for (rendezvousId in rendezvousIds) {218 val viaCache = connectAndSync(219 pinnedCached,220 localIdentity,221 localStaticPrivateKey,222 rendezvousId,223 vaultFactory,224 connectTimeoutMs = cachedTimeoutMs,225 )226 if (viaCache is PairingResult.Success) return viaCache227 }228 }229 val discovered = withTimeoutOrNull(discoveryTimeoutMs) {230 discoverPeers().first { it.deviceId == deviceId }231 } ?: return PairingResult.Error(PairingError.NOT_FOUND)232 val peer = pinDiscoveredPeerToPairedState(paired, discovered)233 var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)234 for (rendezvousId in rendezvousIds) {235 last = connectAndSync(236 peer,237 localIdentity,238 localStaticPrivateKey,239 rendezvousId,240 vaultFactory,241 )242 if (last is PairingResult.Success) return last243 }244 return last245 }246247 private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {248 val attrs = info.attributes ?: return null249 val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null250 if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null251 val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null252 @Suppress("DEPRECATION")253 val host = info.host?.hostAddress ?: return null254 return DiscoveredPeer(255 deviceId = deviceId,256 deviceName = info.serviceName,257 host = host,258 port = info.port,259 remoteStaticPublicKey = hexToPubkey(pkHex),260 )261 }262263 private companion object {264 const val PEACH_SERVICE_TYPE = "_peach._tcp"265 }266}267268data class DiscoveredPeer(269 val deviceId: String,270 val deviceName: String,271 val host: String,272 val port: Int,273 274 val remoteStaticPublicKey: ByteArray,275)276277278279280281282internal fun pinDiscoveredPeerToPairedState(283 paired: PairedDevice,284 discovered: DiscoveredPeer,285): DiscoveredPeer {286 require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }287 val expectedKey = paired.longTermPublicKeyHex288 ?: throw IllegalArgumentException("paired peer missing static key")289 return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))290}291292sealed class PairingResult {293 data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()294 data class Error(val reason: PairingError) : PairingResult()295}296297enum class PairingError {298 NOT_FOUND,299 HANDSHAKE_FAILED,300 VERSION_MISMATCH,301 ALREADY_PAIRED,302 TIMEOUT,303 IO_ERROR,304}