RelayAdoptionHost.kt

8.0 KB · raw

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    /** The source is already occupying the non-buffering room; it is safe to show this. */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        // Pairing is also how this mesh's relay reaches a new peer: the address45        // in the invitation is the one the receiver will dial for ever after.46        // Handing out Peach's relay while the user has asked for a private one47        // would push the same lie one layer down, into the mesh.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                // The relay buffers nothing, so the invitation is not surfaced67                // until the source WebSocket is open and waiting in the room.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                // stop() cancels this job when the user leaves the screen or105                // restarts pairing. That is not a pairing failure — reporting it106                // as one raced the new session and replaced the freshly shown107                // code with "Couldn't pair: StandaloneCoroutine was cancelled".108                // Cancellation must propagate, never be swallowed.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}129130/**131 * Source half of first-contact adoption through an opaque relay splice.132 *133 * The relay token selects only a room. Authentication and confidentiality134 * begin with Noise NK; the root grant remains behind both SAS decisions in135 * [grantAdoption].136 */137internal 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}