RelayAdoptionHost.kt

8.3 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 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        // Pairing is also how this mesh's relay reaches a new peer: the address44        // in the invitation is the one the receiver will dial for ever after.45        // Handing out Peach's relay while the user has asked for a private one46        // would push the same lie one layer down, into the mesh.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                // The relay buffers nothing, so the invitation is not surfaced66                // until the source WebSocket is open and waiting in the room.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                            // Showing a fresh invitation from an unlocked vault86                            // is source-side authorization. The scanning device's87                            // explicit SAS confirmation is the single commit gate.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                // 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 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}