RelayAdoptionHost.kt

10.1 KB · raw

1package com.peachpasswords.sync23import android.util.Log4import java.security.MessageDigest5import javax.inject.Inject6import javax.inject.Singleton7import kotlinx.coroutines.CoroutineScope8import kotlinx.coroutines.Dispatchers9import kotlinx.coroutines.Job10import kotlinx.coroutines.channels.awaitClose11import kotlinx.coroutines.flow.Flow12import kotlinx.coroutines.flow.callbackFlow13import kotlinx.coroutines.launch14import kotlinx.coroutines.withTimeout15import kotlinx.coroutines.withTimeoutOrNull16import org.json.JSONObject1718sealed interface RelayAdoptionEvent {19    /** The source is already occupying the non-buffering room; it is safe to show this. */20    data class InvitationReady(val invitation: String) : RelayAdoptionEvent21    data class AwaitingPeerConfirmation(22        val sas: String,23        val deviceName: String,24    ) : RelayAdoptionEvent25    data class Adopted(val deviceName: String) : RelayAdoptionEvent26    data object Expired : RelayAdoptionEvent27    data class Rejected(val reason: String) : RelayAdoptionEvent28}2930@Singleton31class RelayAdoptionHost @Inject constructor(32    private val relaySettings: RelaySettingsStore,33) {34    private var job: Job? = null35    private var socket: RelayBinarySocket? = null3637    fun start(38        scope: CoroutineScope,39        ourStaticPrivateKey: ByteArray,40        ourStaticPublicKey: ByteArray,41        localIdentity: LocalDeviceIdentity,42        sourceDeps: AdoptionSourceDeps,43        windowMs: Long = PAIRING_WINDOW_MS,44    ): Flow<RelayAdoptionEvent> = callbackFlow {45        // Pairing is also how this mesh's relay reaches a new peer: the address46        // in the invitation is the one the receiver will dial for ever after.47        // Handing out Peach's relay while the user has asked for a private one48        // would push the same lie one layer down, into the mesh.49        val relayUrl = when (val decision = relaySettings.relayInForce()) {50            is RelayDecision.Refuse -> {51                trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))52                close()53                return@callbackFlow54            }5556            is RelayDecision.Dial -> decision.relayUrl57        }58        val token = Rendezvous.newPairingToken()59        val invitation = createPairingInvitation(60            relayUrl,61            token,62            noiseStaticPublicKeyHex = Rendezvous.bytesToHex(ourStaticPublicKey),63        )6465        job = scope.launch(Dispatchers.IO) {66            try {67                val connected = OkHttpRelaySocket.connect(68                    Rendezvous.buildUrl(relayUrl, token),69                )70                socket = connected71                // The relay buffers nothing, so the invitation is not surfaced72                // until the source WebSocket is open and waiting in the room.73                trySend(RelayAdoptionEvent.InvitationReady(invitation))7475                val adopted = withTimeout(windowMs) {76                    runRelayAdoptionSourceSession(77                        socket = connected,78                        expectedToken = token,79                        relayUrl = relayUrl,80                        ourStaticPrivateKey = ourStaticPrivateKey,81                        ourStaticPublicKey = ourStaticPublicKey,82                        localIdentity = localIdentity,83                        sourceDeps = sourceDeps,84                        confirm = { sas, request ->85                            trySend(86                                RelayAdoptionEvent.AwaitingPeerConfirmation(87                                    sas = sas,88                                    deviceName = capDeviceName(request.deviceName.ifBlank { "New device" }),89                                ),90                            )91                            // Showing a fresh invitation from an unlocked vault92                            // is source-side authorization. The scanning device's93                            // explicit SAS confirmation is the single commit gate.94                            true95                        },96                    )97                }98                if (adopted == null) {99                    trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))100                } else {101                    trySend(102                        RelayAdoptionEvent.Adopted(103                            capDeviceName(adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device"),104                        ),105                    )106                }107            } catch (error: kotlinx.coroutines.TimeoutCancellationException) {108                trySend(RelayAdoptionEvent.Expired)109            } catch (error: kotlinx.coroutines.CancellationException) {110                // stop() cancels this job when the user leaves the screen or111                // restarts pairing. That is not a pairing failure — reporting it112                // as one raced the new session and replaced the freshly shown113                // code with "Couldn't pair: StandaloneCoroutine was cancelled".114                // Cancellation must propagate, never be swallowed.115                throw error116            } catch (error: Throwable) {117                trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))118            } finally {119                socket?.close()120                socket = null121                close()122            }123        }124125        awaitClose { stop() }126    }127128    fun stop() {129        job?.cancel()130        job = null131        socket?.close()132        socket = null133    }134}135136/**137 * Source half of first-contact adoption through an opaque relay splice.138 *139 * The relay token selects only a room. Authentication and confidentiality140 * begin with Noise NK; the root grant remains behind both SAS decisions in141 * [grantAdoption].142 */143internal suspend fun runRelayAdoptionSourceSession(144    socket: RelayBinarySocket,145    expectedToken: String,146    relayUrl: String,147    ourStaticPrivateKey: ByteArray,148    ourStaticPublicKey: ByteArray,149    localIdentity: LocalDeviceIdentity,150    sourceDeps: AdoptionSourceDeps,151    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,152): PairedDevice? {153    val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))154    require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }155    require(156        MessageDigest.isEqual(157            expectedToken.toByteArray(Charsets.US_ASCII),158            hello.optString("token").toByteArray(Charsets.US_ASCII),159        ),160    ) { "pairing token mismatch" }161162    socket.send(163        JSONObject()164            .put("type", "PEACH_HELLO_ACK")165            .put(166                "ephemeralPublicKeyHex",167                ourStaticPublicKey.joinToString("") { "%02x".format(it) },168            )169            .toString()170            .toByteArray(Charsets.UTF_8),171    )172173    val noise = noiseNKResponder(174        ourStaticPrivateKey,175        ourStaticPublicKey,176        socket::send,177        socket::receive,178    )179    try {180        val channel = object : PairingTransport {181            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))182            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())183            override suspend fun close() = socket.close()184        }185186        val request = receiveFrame(channel)187        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }188        request as AdoptionFrame.Request189        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null190191        val pairState = AdoptionPairState(192            rendezvousSecretHex = Rendezvous.bytesToHex(193                Rendezvous.deriveSecret(noise.handshakeHash),194            ),195            relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),196        )197        val boundDeps = object : AdoptionSourceDeps {198            override val sourceIdentity: LocalDeviceIdentity = localIdentity199            override val pairState: AdoptionPairState = pairState200            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()201            override suspend fun vaultId(): String = sourceDeps.vaultId()202            override suspend fun license(): String? = sourceDeps.license()203            override suspend fun applyLicense(license: String) {204                sourceDeps.applyLicense(license)205            }206            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true207            override suspend fun upsertPairedDevice(device: PairedDevice) {208                sourceDeps.upsertPairedDevice(device)209            }210            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()211            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()212            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)213            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()214        }215        val adopted = grantAdoption(channel, request, boundDeps) ?: return null216        // If the receiver advertised snapshot support, send the vault inline.217        if (request.snapshotCapable == true) {218            try {219                val snapshot = boundDeps.vaultSnapshot()220                if (snapshot != null) {221                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()222                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)223                    val applied = receiveFrame(channel)224                    if (applied is AdoptionFrame.SnapshotApplied) {225                        val manifest = boundDeps.outgoingDeviceManifest()226                        if (manifest != null) {227                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))228                            withTimeoutOrNull(5_000) { receiveFrame(channel) }229                        }230                        boundDeps.markSynced(adopted.deviceId)231                    }232                }233            } catch (error: Exception) {234                if (error is SnapshotDuressFrameTooLargeException) throw error235                if (error is kotlinx.coroutines.CancellationException) throw error236                Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)237            }238        }239        return adopted240    } finally {241        noise.close()242    }243}