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: LocalChirpIdentity,41 sourceDeps: AdoptionSourceDeps,42 windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS,43 ): Flow<RelayAdoptionEvent> = callbackFlow {44 val relayUrl = relaySettings.relayUrl()45 val token = Rendezvous.newPairingToken()46 val invitation = createPairingInvitation(relayUrl, token)4748 job = scope.launch(Dispatchers.IO) {49 try {50 val connected = OkHttpRelaySocket.connect(51 Rendezvous.buildUrl(relayUrl, token),52 )53 socket = connected54 55 56 trySend(RelayAdoptionEvent.InvitationReady(invitation))5758 val adopted = withTimeout(windowMs) {59 runRelayAdoptionSourceSession(60 socket = connected,61 expectedToken = token,62 relayUrl = relayUrl,63 ourStaticPrivateKey = ourStaticPrivateKey,64 ourStaticPublicKey = ourStaticPublicKey,65 localIdentity = localIdentity,66 sourceDeps = sourceDeps,67 confirm = { sas, request ->68 val pending = PairingSession()69 trySend(70 RelayAdoptionEvent.AwaitingConfirmation(71 sas = sas,72 deviceName = request.deviceName.ifBlank { "New device" },73 session = pending,74 ),75 )76 pending.await()77 },78 )79 }80 if (adopted == null) {81 trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))82 } else {83 trySend(84 RelayAdoptionEvent.Adopted(85 adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device",86 ),87 )88 }89 } catch (error: kotlinx.coroutines.TimeoutCancellationException) {90 trySend(RelayAdoptionEvent.Expired)91 } catch (error: Throwable) {92 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))93 } finally {94 socket?.close()95 socket = null96 close()97 }98 }99100 awaitClose { stop() }101 }102103 fun stop() {104 job?.cancel()105 job = null106 socket?.close()107 socket = null108 }109}110111112113114115116117118internal suspend fun runRelayAdoptionSourceSession(119 socket: RelayBinarySocket,120 expectedToken: String,121 relayUrl: String,122 ourStaticPrivateKey: ByteArray,123 ourStaticPublicKey: ByteArray,124 localIdentity: LocalChirpIdentity,125 sourceDeps: AdoptionSourceDeps,126 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,127): PairedDevice? {128 val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))129 require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }130 require(131 MessageDigest.isEqual(132 expectedToken.toByteArray(Charsets.US_ASCII),133 hello.optString("token").toByteArray(Charsets.US_ASCII),134 ),135 ) { "pairing token mismatch" }136137 socket.send(138 JSONObject()139 .put("type", "PEACH_HELLO_ACK")140 .put(141 "ephemeralPublicKeyHex",142 ourStaticPublicKey.joinToString("") { "%02x".format(it) },143 )144 .toString()145 .toByteArray(Charsets.UTF_8),146 )147148 val noise = noiseNKResponder(149 ourStaticPrivateKey,150 ourStaticPublicKey,151 socket::send,152 socket::receive,153 )154 try {155 val channel = object : PairingTransport {156 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))157 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())158 override suspend fun close() = socket.close()159 }160161 val request = receiveFrame(channel)162 require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }163 request as AdoptionFrame.Request164 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null165166 val pairState = AdoptionPairState(167 rendezvousSecretHex = Rendezvous.bytesToHex(168 Rendezvous.deriveSecret(noise.handshakeHash),169 ),170 relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),171 )172 val boundDeps = object : AdoptionSourceDeps {173 override val sourceIdentity: LocalChirpIdentity = localIdentity174 override val pairState: AdoptionPairState = pairState175 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()176 override suspend fun vaultId(): String = sourceDeps.vaultId()177 override suspend fun license(): String? = sourceDeps.license()178 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true179 override suspend fun upsertPairedDevice(device: PairedDevice) {180 sourceDeps.upsertPairedDevice(device)181 }182 }183 return grantAdoption(channel, request, boundDeps)184 } finally {185 noise.close()186 }187}