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 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 143 144 transport.send(session.encrypt(encodePairIdentityFrame(localIdentity)))145 val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))146 require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }147 require(148 theirIdentity.longTermPublicKeyHex.equals(149 pubkeyToHex(peer.remoteStaticPublicKey),150 ignoreCase = true,151 ),152 ) { "peer identity does not match authenticated KK key" }153154 val sas = SASDerivation.derive(session.handshakeHash)155 val incoming = pairIdentityToPairedDevice(theirIdentity)156 val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())157 ?: deviceStore.get(incoming.deviceId)158 val device = incoming.copy(159 rendezvousSecretHex = existing?.rendezvousSecretHex,160 relayUrl = existing?.relayUrl ?: incoming.relayUrl,161 )162 deviceStore.upsert(device)163164 165 val vault = vaultFactory()166 if (vault != null) {167 vault.bindAuthenticatedPeer(device)168 val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)169 val outcome = runChirpSyncInitiator(syncSession, vault)170 if (outcome.status == SyncOutcomeStatus.COMPLETED) {171 deviceStore.updateLastSync(device.deviceId)172 }173 }174175 transport.close()176 PairingResult.Success(device, sas)177 } catch (e: SocketTimeoutException) {178 runCatching { socket.close() }179 PairingResult.Error(PairingError.TIMEOUT)180 } catch (e: Throwable) {181 runCatching { socket.close() }182 PairingResult.Error(PairingError.IO_ERROR)183 } finally {184 noiseSession?.close()185 }186 }187188 189190191192193194195196197198199200201 suspend fun syncWithKnownDevice(202 deviceId: String,203 localIdentity: LocalChirpIdentity,204 localStaticPrivateKey: ByteArray,205 vaultFactory: () -> ChirpSyncVaultAdapter?,206 discoveryTimeoutMs: Long = 5_000L,207 cached: DiscoveredPeer? = null,208 cachedTimeoutMs: Int = 2_000,209 ): PairingResult {210 val paired = deviceStore.get(deviceId)211 ?: return PairingResult.Error(PairingError.NOT_FOUND)212 val secret = paired.rendezvousSecretHex213 ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }214 ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)215 val rendezvousIds = Rendezvous.dialingIds(216 secret,217 System.currentTimeMillis() / 1000L,218 )219 if (cached != null) {220 val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)221 for (rendezvousId in rendezvousIds) {222 val viaCache = connectAndSync(223 pinnedCached,224 localIdentity,225 localStaticPrivateKey,226 rendezvousId,227 vaultFactory,228 connectTimeoutMs = cachedTimeoutMs,229 )230 if (viaCache is PairingResult.Success) return viaCache231 }232 }233 val discovered = withTimeoutOrNull(discoveryTimeoutMs) {234 discoverPeers().first { it.deviceId == deviceId }235 } ?: return PairingResult.Error(PairingError.NOT_FOUND)236 val peer = pinDiscoveredPeerToPairedState(paired, discovered)237 var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)238 for (rendezvousId in rendezvousIds) {239 last = connectAndSync(240 peer,241 localIdentity,242 localStaticPrivateKey,243 rendezvousId,244 vaultFactory,245 )246 if (last is PairingResult.Success) return last247 }248 return last249 }250251 private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {252 val attrs = info.attributes ?: return null253 val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null254 if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null255 val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null256 @Suppress("DEPRECATION")257 val host = info.host?.hostAddress ?: return null258 return DiscoveredPeer(259 deviceId = deviceId,260 deviceName = info.serviceName,261 host = host,262 port = info.port,263 remoteStaticPublicKey = hexToPubkey(pkHex),264 )265 }266267 private companion object {268 const val PEACH_SERVICE_TYPE = "_peach._tcp"269 }270}271272data class DiscoveredPeer(273 val deviceId: String,274 val deviceName: String,275 val host: String,276 val port: Int,277 278 val remoteStaticPublicKey: ByteArray,279)280281282283284285286internal fun pinDiscoveredPeerToPairedState(287 paired: PairedDevice,288 discovered: DiscoveredPeer,289): DiscoveredPeer {290 require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }291 val expectedKey = paired.longTermPublicKeyHex292 ?: throw IllegalArgumentException("paired peer missing static key")293 return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))294}295296sealed class PairingResult {297 data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()298 data class Error(val reason: PairingError) : PairingResult()299}300301enum class PairingError {302 NOT_FOUND,303 HANDSHAKE_FAILED,304 VERSION_MISMATCH,305 ALREADY_PAIRED,306 TIMEOUT,307 IO_ERROR,308}