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(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))145 val theirIdentity = parsePairIdentityFrame(146 SyncPadding.unpad(session.decrypt(transport.receive())),147 )148 require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }149 require(150 CryptoCompare.hex(151 theirIdentity.longTermPublicKeyHex,152 pubkeyToHex(peer.remoteStaticPublicKey),153 ),154 ) { "peer identity does not match authenticated KK key" }155156 val sas = SASDerivation.derive(session.handshakeHash)157 val incoming = pairIdentityToPairedDevice(theirIdentity)158 val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())159 ?: deviceStore.get(incoming.deviceId)160 val device = incoming.copy(161 rendezvousSecretHex = existing?.rendezvousSecretHex,162 relayUrl = existing?.relayUrl ?: incoming.relayUrl,163 )164 deviceStore.upsert(device)165166 167 val vault = vaultFactory()168 if (vault != null) {169 vault.bindAuthenticatedPeer(device)170 val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)171 val outcome = runChirpSyncInitiator(syncSession, vault)172 if (outcome.status == SyncOutcomeStatus.COMPLETED) {173 deviceStore.updateLastSync(device.deviceId)174 }175 }176177 transport.close()178 PairingResult.Success(device, sas)179 } catch (e: SocketTimeoutException) {180 runCatching { socket.close() }181 PairingResult.Error(PairingError.TIMEOUT)182 } catch (e: Throwable) {183 runCatching { socket.close() }184 PairingResult.Error(PairingError.IO_ERROR)185 } finally {186 noiseSession?.close()187 }188 }189190 191192193194195196197198199200201202203 suspend fun syncWithKnownDevice(204 deviceId: String,205 localIdentity: LocalChirpIdentity,206 localStaticPrivateKey: ByteArray,207 vaultFactory: () -> ChirpSyncVaultAdapter?,208 discoveryTimeoutMs: Long = 5_000L,209 cached: DiscoveredPeer? = null,210 cachedTimeoutMs: Int = 2_000,211 ): PairingResult {212 val paired = deviceStore.get(deviceId)213 ?: return PairingResult.Error(PairingError.NOT_FOUND)214 val secret = paired.rendezvousSecretHex215 ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }216 ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)217 val rendezvousIds = Rendezvous.dialingIds(218 secret,219 System.currentTimeMillis() / 1000L,220 )221 if (cached != null) {222 val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)223 for (rendezvousId in rendezvousIds) {224 val viaCache = connectAndSync(225 pinnedCached,226 localIdentity,227 localStaticPrivateKey,228 rendezvousId,229 vaultFactory,230 connectTimeoutMs = cachedTimeoutMs,231 )232 if (viaCache is PairingResult.Success) return viaCache233 }234 }235 val discovered = withTimeoutOrNull(discoveryTimeoutMs) {236 discoverPeers().first { it.deviceId == deviceId }237 } ?: return PairingResult.Error(PairingError.NOT_FOUND)238 val peer = pinDiscoveredPeerToPairedState(paired, discovered)239 var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)240 for (rendezvousId in rendezvousIds) {241 last = connectAndSync(242 peer,243 localIdentity,244 localStaticPrivateKey,245 rendezvousId,246 vaultFactory,247 )248 if (last is PairingResult.Success) return last249 }250 return last251 }252253 private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {254 val attrs = info.attributes ?: return null255 val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null256 if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null257 val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null258 @Suppress("DEPRECATION")259 val host = info.host?.hostAddress ?: return null260 return DiscoveredPeer(261 deviceId = deviceId,262 deviceName = info.serviceName,263 host = host,264 port = info.port,265 remoteStaticPublicKey = hexToPubkey(pkHex),266 )267 }268269 private companion object {270 const val PEACH_SERVICE_TYPE = "_peach._tcp"271 }272}273274data class DiscoveredPeer(275 val deviceId: String,276 val deviceName: String,277 val host: String,278 val port: Int,279 280 val remoteStaticPublicKey: ByteArray,281)282283284285286287288internal fun pinDiscoveredPeerToPairedState(289 paired: PairedDevice,290 discovered: DiscoveredPeer,291): DiscoveredPeer {292 require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }293 val expectedKey = paired.longTermPublicKeyHex294 ?: throw IllegalArgumentException("paired peer missing static key")295 return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))296}297298sealed class PairingResult {299 data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()300 data class Error(val reason: PairingError) : PairingResult()301}302303enum class PairingError {304 NOT_FOUND,305 HANDSHAKE_FAILED,306 VERSION_MISMATCH,307 ALREADY_PAIRED,308 TIMEOUT,309 IO_ERROR,310}