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 AwaitingPeerConfirmation(20 val sas: String,21 val deviceName: String,22 ) : RelayAdoptionEvent23 data class Adopted(val deviceName: String) : RelayAdoptionEvent24 data object Expired : RelayAdoptionEvent25 data class Rejected(val reason: String) : RelayAdoptionEvent26}2728@Singleton29class RelayAdoptionHost @Inject constructor(30 private val relaySettings: RelaySettingsStore,31) {32 private var job: Job? = null33 private var socket: RelayBinarySocket? = null3435 fun start(36 scope: CoroutineScope,37 ourStaticPrivateKey: ByteArray,38 ourStaticPublicKey: ByteArray,39 localIdentity: LocalDeviceIdentity,40 sourceDeps: AdoptionSourceDeps,41 windowMs: Long = PAIRING_WINDOW_MS,42 ): Flow<RelayAdoptionEvent> = callbackFlow {43 44 45 46 47 val relayUrl = when (val decision = relaySettings.relayInForce()) {48 is RelayDecision.Refuse -> {49 trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))50 close()51 return@callbackFlow52 }5354 is RelayDecision.Dial -> decision.relayUrl55 }56 val token = Rendezvous.newPairingToken()57 val invitation = createPairingInvitation(relayUrl, token)5859 job = scope.launch(Dispatchers.IO) {60 try {61 val connected = OkHttpRelaySocket.connect(62 Rendezvous.buildUrl(relayUrl, token),63 )64 socket = connected65 66 67 trySend(RelayAdoptionEvent.InvitationReady(invitation))6869 val adopted = withTimeout(windowMs) {70 runRelayAdoptionSourceSession(71 socket = connected,72 expectedToken = token,73 relayUrl = relayUrl,74 ourStaticPrivateKey = ourStaticPrivateKey,75 ourStaticPublicKey = ourStaticPublicKey,76 localIdentity = localIdentity,77 sourceDeps = sourceDeps,78 confirm = { sas, request ->79 trySend(80 RelayAdoptionEvent.AwaitingPeerConfirmation(81 sas = sas,82 deviceName = request.deviceName.ifBlank { "New device" },83 ),84 )85 86 87 88 true89 },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 applyLicense(license: String) {198 sourceDeps.applyLicense(license)199 }200 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true201 override suspend fun upsertPairedDevice(device: PairedDevice) {202 sourceDeps.upsertPairedDevice(device)203 }204 }205 return grantAdoption(channel, request, boundDeps)206 } finally {207 noise.close()208 }209}