1package com.peachpasswords.sync23import java.security.MessageDigest4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CoroutineScope7import kotlinx.coroutines.Dispatchers8import kotlinx.coroutines.Job9import kotlinx.coroutines.channels.awaitClose10import kotlinx.coroutines.flow.Flow11import kotlinx.coroutines.flow.callbackFlow12import kotlinx.coroutines.launch13import kotlinx.coroutines.withTimeout14import org.json.JSONObject1516sealed interface RelayAdoptionEvent {17 18 data class InvitationReady(val invitation: String) : RelayAdoptionEvent19 data class AwaitingConfirmation(20 val sas: String,21 val deviceName: String,22 val session: PairingSession,23 ) : RelayAdoptionEvent24 data class Adopted(val deviceName: String) : RelayAdoptionEvent25 data object Expired : RelayAdoptionEvent26 data class Rejected(val reason: String) : RelayAdoptionEvent27}2829@Singleton30class RelayAdoptionHost @Inject constructor(31 private val relaySettings: RelaySettingsStore,32) {33 private var job: Job? = null34 private var socket: RelayBinarySocket? = null3536 fun start(37 scope: CoroutineScope,38 ourStaticPrivateKey: ByteArray,39 ourStaticPublicKey: ByteArray,40 localIdentity: LocalDeviceIdentity,41 sourceDeps: AdoptionSourceDeps,42 windowMs: Long = PAIRING_WINDOW_MS,43 ): Flow<RelayAdoptionEvent> = callbackFlow {44 45 46 47 48 val relayUrl = when (val decision = relaySettings.relayInForce()) {49 is RelayDecision.Refuse -> {50 trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))51 close()52 return@callbackFlow53 }5455 is RelayDecision.Dial -> decision.relayUrl56 }57 val token = Rendezvous.newPairingToken()58 val invitation = createPairingInvitation(relayUrl, token)5960 job = scope.launch(Dispatchers.IO) {61 try {62 val connected = OkHttpRelaySocket.connect(63 Rendezvous.buildUrl(relayUrl, token),64 )65 socket = connected66 67 68 trySend(RelayAdoptionEvent.InvitationReady(invitation))6970 val adopted = withTimeout(windowMs) {71 runRelayAdoptionSourceSession(72 socket = connected,73 expectedToken = token,74 relayUrl = relayUrl,75 ourStaticPrivateKey = ourStaticPrivateKey,76 ourStaticPublicKey = ourStaticPublicKey,77 localIdentity = localIdentity,78 sourceDeps = sourceDeps,79 confirm = { sas, request ->80 val pending = PairingSession()81 trySend(82 RelayAdoptionEvent.AwaitingConfirmation(83 sas = sas,84 deviceName = request.deviceName.ifBlank { "New device" },85 session = pending,86 ),87 )88 pending.await()89 },90 )91 }92 if (adopted == null) {93 trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))94 } else {95 trySend(96 RelayAdoptionEvent.Adopted(97 adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device",98 ),99 )100 }101 } catch (error: kotlinx.coroutines.TimeoutCancellationException) {102 trySend(RelayAdoptionEvent.Expired)103 } catch (error: kotlinx.coroutines.CancellationException) {104 105 106 107 108 109 throw error110 } catch (error: Throwable) {111 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))112 } finally {113 socket?.close()114 socket = null115 close()116 }117 }118119 awaitClose { stop() }120 }121122 fun stop() {123 job?.cancel()124 job = null125 socket?.close()126 socket = null127 }128}129130131132133134135136137internal suspend fun runRelayAdoptionSourceSession(138 socket: RelayBinarySocket,139 expectedToken: String,140 relayUrl: String,141 ourStaticPrivateKey: ByteArray,142 ourStaticPublicKey: ByteArray,143 localIdentity: LocalDeviceIdentity,144 sourceDeps: AdoptionSourceDeps,145 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,146): PairedDevice? {147 val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))148 require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }149 require(150 MessageDigest.isEqual(151 expectedToken.toByteArray(Charsets.US_ASCII),152 hello.optString("token").toByteArray(Charsets.US_ASCII),153 ),154 ) { "pairing token mismatch" }155156 socket.send(157 JSONObject()158 .put("type", "PEACH_HELLO_ACK")159 .put(160 "ephemeralPublicKeyHex",161 ourStaticPublicKey.joinToString("") { "%02x".format(it) },162 )163 .toString()164 .toByteArray(Charsets.UTF_8),165 )166167 val noise = noiseNKResponder(168 ourStaticPrivateKey,169 ourStaticPublicKey,170 socket::send,171 socket::receive,172 )173 try {174 val channel = object : PairingTransport {175 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))176 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())177 override suspend fun close() = socket.close()178 }179180 val request = receiveFrame(channel)181 require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }182 request as AdoptionFrame.Request183 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null184185 val pairState = AdoptionPairState(186 rendezvousSecretHex = Rendezvous.bytesToHex(187 Rendezvous.deriveSecret(noise.handshakeHash),188 ),189 relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),190 )191 val boundDeps = object : AdoptionSourceDeps {192 override val sourceIdentity: LocalDeviceIdentity = localIdentity193 override val pairState: AdoptionPairState = pairState194 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()195 override suspend fun vaultId(): String = sourceDeps.vaultId()196 override suspend fun license(): String? = sourceDeps.license()197 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true198 override suspend fun upsertPairedDevice(device: PairedDevice) {199 sourceDeps.upsertPairedDevice(device)200 }201 }202 return grantAdoption(channel, request, boundDeps)203 } finally {204 noise.close()205 }206}