RelayAdoptionHost.kt

10.1 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 kotlinx.coroutines.withTimeoutOrNull15import org.json.JSONObject1617sealed interface RelayAdoptionEvent {18    /** The source is already occupying the non-buffering room; it is safe to show this. */19    data class InvitationReady(val invitation: String) : RelayAdoptionEvent20    data class AwaitingPeerConfirmation(21        val sas: String,22        val deviceName: String,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(59            relayUrl,60            token,61            noiseStaticPublicKeyHex = Rendezvous.bytesToHex(ourStaticPublicKey),62        )6364        job = scope.launch(Dispatchers.IO) {65            try {66                val connected = OkHttpRelaySocket.connectPairing(67                    Rendezvous.buildUrl(relayUrl, token),68                )69                socket = connected70                // The relay buffers nothing, so the invitation is not surfaced71                // until the source WebSocket is open and waiting in the room.72                trySend(RelayAdoptionEvent.InvitationReady(invitation))7374                val adopted = withTimeout(windowMs) {75                    runRelayAdoptionSourceSession(76                        socket = connected,77                        expectedToken = token,78                        relayUrl = relayUrl,79                        ourStaticPrivateKey = ourStaticPrivateKey,80                        ourStaticPublicKey = ourStaticPublicKey,81                        localIdentity = localIdentity,82                        sourceDeps = sourceDeps,83                        confirm = { sas, request ->84                            trySend(85                                RelayAdoptionEvent.AwaitingPeerConfirmation(86                                    sas = sas,87                                    deviceName = capDeviceName(request.deviceName.ifBlank { "New device" }),88                                ),89                            )90                            // Showing a fresh invitation from an unlocked vault91                            // is source-side authorization. The scanning device's92                            // explicit SAS confirmation is the single commit gate.93                            true94                        },95                    )96                }97                if (adopted == null) {98                    trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))99                } else {100                    trySend(101                        RelayAdoptionEvent.Adopted(102                            capDeviceName(adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device"),103                        ),104                    )105                }106            } catch (error: kotlinx.coroutines.TimeoutCancellationException) {107                trySend(RelayAdoptionEvent.Expired)108            } catch (error: kotlinx.coroutines.CancellationException) {109                // stop() cancels this job when the user leaves the screen or110                // restarts pairing. That is not a pairing failure — reporting it111                // as one raced the new session and replaced the freshly shown112                // code with "Couldn't pair: StandaloneCoroutine was cancelled".113                // Cancellation must propagate, never be swallowed.114                throw error115            } catch (error: Throwable) {116                trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))117            } finally {118                socket?.close()119                socket = null120                close()121            }122        }123124        awaitClose { stop() }125    }126127    fun stop() {128        job?.cancel()129        job = null130        socket?.close()131        socket = null132    }133}134135/**136 * Source half of first-contact adoption through an opaque relay splice.137 *138 * The relay token selects only a room. Authentication and confidentiality139 * begin with Noise NK; the root grant remains behind both SAS decisions in140 * [grantAdoption].141 */142internal suspend fun runRelayAdoptionSourceSession(143    socket: RelayBinarySocket,144    expectedToken: String,145    relayUrl: String,146    ourStaticPrivateKey: ByteArray,147    ourStaticPublicKey: ByteArray,148    localIdentity: LocalDeviceIdentity,149    sourceDeps: AdoptionSourceDeps,150    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,151): PairedDevice? {152    val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))153    require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }154    require(155        MessageDigest.isEqual(156            expectedToken.toByteArray(Charsets.US_ASCII),157            hello.optString("token").toByteArray(Charsets.US_ASCII),158        ),159    ) { "pairing token mismatch" }160161    socket.send(162        JSONObject()163            .put("type", "PEACH_HELLO_ACK")164            .put(165                "ephemeralPublicKeyHex",166                ourStaticPublicKey.joinToString("") { "%02x".format(it) },167            )168            .toString()169            .toByteArray(Charsets.UTF_8),170    )171172    val noise = noiseNKResponder(173        ourStaticPrivateKey,174        ourStaticPublicKey,175        socket::send,176        socket::receive,177    )178    try {179        val channel = object : PairingTransport {180            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))181            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())182            override suspend fun close() = socket.close()183        }184185        val request = receiveFrame(channel)186        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }187        request as AdoptionFrame.Request188        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null189190        val pairState = AdoptionPairState(191            rendezvousSecretHex = Rendezvous.bytesToHex(192                Rendezvous.deriveSecret(noise.handshakeHash),193            ),194            relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),195        )196        val boundDeps = object : AdoptionSourceDeps {197            override val sourceIdentity: LocalDeviceIdentity = localIdentity198            override val pairState: AdoptionPairState = pairState199            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()200            override suspend fun vaultId(): String = sourceDeps.vaultId()201            override suspend fun license(): String? = sourceDeps.license()202            override suspend fun applyLicense(license: String) {203                sourceDeps.applyLicense(license)204            }205            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true206            override suspend fun upsertPairedDevice(device: PairedDevice) {207                sourceDeps.upsertPairedDevice(device)208            }209            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()210            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()211            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)212            override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()213        }214        val adopted = grantAdoption(channel, request, boundDeps) ?: return null215        // If the receiver advertised snapshot support, send the vault inline.216        if (request.snapshotCapable == true) {217            try {218                val snapshot = boundDeps.vaultSnapshot()219                if (snapshot != null) {220                    val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()221                    sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)222                    val applied = receiveFrame(channel)223                    if (applied is AdoptionFrame.SnapshotApplied) {224                        val manifest = boundDeps.outgoingDeviceManifest()225                        if (manifest != null) {226                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))227                            withTimeoutOrNull(5_000) { receiveFrame(channel) }228                        }229                        boundDeps.markSynced(adopted.deviceId)230                    }231                }232            } catch (error: Exception) {233                if (error is SnapshotDuressFrameTooLargeException) throw error234                if (error is kotlinx.coroutines.CancellationException) throw error235                PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")236            }237        }238        return adopted239    } finally {240        noise.close()241    }242}