1package com.peachpasswords.sync23import java.security.MessageDigest4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CoroutineScope7import kotlinx.coroutines.CompletableDeferred8import kotlinx.coroutines.Dispatchers9import kotlinx.coroutines.Job10import kotlinx.coroutines.channels.awaitClose11import kotlinx.coroutines.flow.Flow12import kotlinx.coroutines.flow.callbackFlow13import kotlinx.coroutines.launch14import kotlinx.coroutines.withTimeout15import kotlinx.coroutines.withTimeoutOrNull16import org.json.JSONObject1718sealed interface RelayAdoptionEvent {19 20 data class InvitationReady(val invitation: String) : RelayAdoptionEvent21 data class AwaitingPeerConfirmation(22 val sas: String,23 val deviceName: String,24 ) : RelayAdoptionEvent25 data class Adopted(val deviceName: String) : RelayAdoptionEvent26 data object Expired : RelayAdoptionEvent27 data class Rejected(val reason: String) : RelayAdoptionEvent28}2930@Singleton31class RelayAdoptionHost @Inject constructor(32 private val relaySettings: RelaySettingsStore,33 private val generation: SyncGenerationPort,34 private val deviceStore: PairedDeviceRegistry,35) {36 private var job: Job? = null37 private var socket: RelayBinarySocket? = null38 private val consent = SourceConsentGate()3940 4142434445 fun confirmSas(matches: Boolean) = consent.resolve(matches)4647 fun start(48 scope: CoroutineScope,49 ourStaticPrivateKey: ByteArray,50 ourStaticPublicKey: ByteArray,51 localIdentity: LocalDeviceIdentity,52 sourceDeps: AdoptionSourceV2Deps,53 windowMs: Long = PAIRING_WINDOW_MS,54 ): Flow<RelayAdoptionEvent> = callbackFlow {55 56 57 58 59 val relayUrl = when (val decision = relaySettings.relayInForce()) {60 is RelayDecision.Refuse -> {61 trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))62 close()63 return@callbackFlow64 }6566 is RelayDecision.Dial -> decision.relayUrl67 }68 val token = Rendezvous.newPairingToken()69 val invitation = createPairingInvitation(70 relayUrl,71 token,72 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(ourStaticPublicKey),73 )7475 job = scope.launch(Dispatchers.IO) {76 try {77 val connected = OkHttpRelaySocket.connectPairing(78 Rendezvous.buildUrl(relayUrl, token),79 )80 socket = connected81 82 83 trySend(RelayAdoptionEvent.InvitationReady(invitation))8485 var peerOutdated = false86 val adopted = withTimeout(windowMs) {87 runRelayAdoptionSourceSession(88 socket = connected,89 expectedToken = token,90 relayUrl = relayUrl,91 ourStaticPrivateKey = ourStaticPrivateKey,92 ourStaticPublicKey = ourStaticPublicKey,93 localIdentity = localIdentity,94 sourceDeps = sourceDeps,95 96 97 98 99 postReset = { generation.isPostReset(System.currentTimeMillis()) },100 onPeerOutdated = { peerOutdated = true },101 confirm = { sas, request ->102 trySend(103 RelayAdoptionEvent.AwaitingPeerConfirmation(104 sas = sas,105 deviceName = capDeviceName(request.deviceName.ifBlank { "New device" }),106 ),107 )108 109 110 111 112 consent.await()113 },114 )115 }116 if (peerOutdated) {117 trySend(RelayAdoptionEvent.Rejected(PEER_PROTOCOL_OUTDATED))118 } else if (adopted == null) {119 trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))120 } else {121 onDirectV2PairingEstablished(122 generation,123 hasDirectDevice = deviceStore.getAll().any { it.trustSource == "direct" },124 nowMs = System.currentTimeMillis(),125 )126 trySend(127 RelayAdoptionEvent.Adopted(128 capDeviceName(adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device"),129 ),130 )131 }132 } catch (error: kotlinx.coroutines.TimeoutCancellationException) {133 trySend(RelayAdoptionEvent.Expired)134 } catch (error: kotlinx.coroutines.CancellationException) {135 136 137 138 139 140 throw error141 } catch (error: Throwable) {142 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))143 } finally {144 socket?.close()145 socket = null146 close()147 }148 }149150 awaitClose { stop() }151 }152153 fun stop() {154 155 156 consent.resolve(false)157 job?.cancel()158 job = null159 socket?.close()160 socket = null161 }162}163164165166167168169170171172173internal class SourceConsentGate {174 private val lock = Any()175 private var pending: CompletableDeferred<Boolean>? = null176177 178 suspend fun await(): Boolean {179 val deferred = synchronized(lock) {180 CompletableDeferred<Boolean>().also { pending = it }181 }182 return try {183 deferred.await()184 } finally {185 synchronized(lock) { if (pending === deferred) pending = null }186 }187 }188189 190 fun resolve(matches: Boolean) {191 val deferred = synchronized(lock) { pending.also { pending = null } } ?: return192 deferred.complete(matches)193 }194}195196197198199200201202203internal suspend fun runRelayAdoptionSourceSession(204 socket: RelayBinarySocket,205 expectedToken: String,206 relayUrl: String,207 ourStaticPrivateKey: ByteArray,208 ourStaticPublicKey: ByteArray,209 localIdentity: LocalDeviceIdentity,210 sourceDeps: AdoptionSourceV2Deps,211 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,212 postReset: () -> Boolean = { false },213 onPeerOutdated: () -> Unit = {},214): PairedDevice? {215 val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))216 require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }217 require(218 MessageDigest.isEqual(219 expectedToken.toByteArray(Charsets.US_ASCII),220 hello.optString("token").toByteArray(Charsets.US_ASCII),221 ),222 ) { "pairing token mismatch" }223224 socket.send(225 JSONObject()226 .put("type", "PEACH_HELLO_ACK")227 .put(228 "ephemeralPublicKeyHex",229 ourStaticPublicKey.joinToString("") { "%02x".format(it) },230 )231 .toString()232 .toByteArray(Charsets.UTF_8),233 )234235 val noise = noiseNKResponder(236 ourStaticPrivateKey,237 ourStaticPublicKey,238 socket::send,239 socket::receive,240 )241 try {242 val channel = object : PairingTransport {243 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))244 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())245 override suspend fun close() = socket.close()246 }247248 val request = receiveFrame(channel)249 require(request is AdoptionFrame.Request || request is AdoptionFrame.RequestV2) { "expected ADOPT_REQUEST" }250 251 252 253 if (request is AdoptionFrame.Request && !allowLegacyV1(postReset())) {254 onPeerOutdated()255 return null256 }257 if (request is AdoptionFrame.Request && !confirm(SASDerivation.derive(noise.handshakeHash), request)) return null258259 val pairState = AdoptionPairState(260 rendezvousSecretHex = Rendezvous.bytesToHex(261 Rendezvous.deriveSecret(noise.handshakeHash),262 ),263 relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),264 )265 266 267 val boundDeps = bindSourceSessionDeps(268 sourceDeps = sourceDeps,269 sourceIdentity = localIdentity,270 pairState = pairState,271 sas = SASDerivation.derive(noise.handshakeHash),272 snapshotCapable = sourceDeps.snapshotCapable(),273 confirm = confirm,274 )275 val adopted = when (request) {276 is AdoptionFrame.RequestV2 -> grantAdoptionV2(channel, request, boundDeps)277 is AdoptionFrame.Request -> grantAdoption(channel, request, boundDeps)278 else -> null279 } ?: return null280 281 val receiverWantsSnapshot = when (request) {282 is AdoptionFrame.Request -> request.snapshotCapable == true283 is AdoptionFrame.RequestV2 -> request.snapshotCapable == true284 else -> false285 }286 if (receiverWantsSnapshot) {287 try {288 val snapshot = boundDeps.vaultSnapshot()289 if (snapshot != null) {290 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()291 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)292 val applied = receiveFrame(channel)293 if (applied is AdoptionFrame.SnapshotApplied) {294 val manifest = boundDeps.outgoingDeviceManifest()295 if (manifest != null) {296 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))297 withTimeoutOrNull(5_000) { receiveFrame(channel) }298 }299 boundDeps.markSynced(adopted.deviceId)300 }301 }302 } catch (error: Exception) {303 if (error is SnapshotDuressFrameTooLargeException) throw error304 if (error is kotlinx.coroutines.CancellationException) throw error305 PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")306 }307 }308 return adopted309 } finally {310 noise.close()311 }312}