CloudRelayDialer.kt

4.8 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.CancellationException4import org.json.JSONObject56/**7 * One relayed reconnect session where WE speak first — the mirror of8 * [runCloudRelayResponderSession], and the half Android never had.9 *10 * Until this existed the phone could only ever wait, which was fine against a11 * browser extension (it can only dial) and a deadlock against another phone:12 * two waiters in the same room, nobody opening. See [weDialPeer] for how the13 * role is assigned.14 *15 * The wire is the extension's, byte for byte: an unencrypted `PEACH_RECONNECT`16 * selector naming the room, then Noise KK, then a padded PAIR_IDENTITY each17 * way, then the sync exchange.18 */19internal suspend fun runCloudRelayInitiatorSession(20    socket: RelayBinarySocket,21    known: PairedDevice,22    rendezvousId: String,23    ourStaticPrivateKey: ByteArray,24    ourStaticPublicKey: ByteArray,25    localIdentity: LocalDeviceIdentity,26    vaultFactory: () -> SyncVaultAdapter?,27    onSyncComplete: () -> Unit,28    report: (SyncOutcomeSummary) -> Unit = {},29) {30    PeachSyncLog.i(31        "cloud-relay: dial — rendezvous=$rendezvousId, " +32            "peer=${relayLogDetail(known.deviceName ?: known.deviceId)}",33    )34    try {35        val responderKeyHex = known.longTermPublicKeyHex36            ?: throw IllegalArgumentException("known peer missing static key")37        val responderKey = hexToPubkey(responderKeyHex)3839        // The capability names the room only; it grants no trust. KK proves both40        // private keys immediately after, and a wrong room is refused there.41        socket.send(42            JSONObject()43                .put("type", "PEACH_RECONNECT")44                .put("rendezvous_id", rendezvousId)45                .toString()46                .toByteArray(Charsets.UTF_8),47        )48        val session = noiseKKInitiator(49            ourStaticPrivateKey,50            ourStaticPublicKey,51            responderKey,52            socket::send,53            socket::receive,54        )55        // KK completed, so a peer is really there and answered. Everything past this56        // point is a reportable outcome; everything before it is a dial into an57        // empty room, which is the resting state of a phone, not a failure.58        try {59            sendSyncFrame(60                encodePairIdentityFrame(localIdentity),61                session::encrypt,62                socket::send,63            )64            val theirIdentity = parsePairIdentityFrame(65                receiveSyncFrame(socket::receive, session::decrypt),66            )67            require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {68                "peer identity does not match authenticated KK key"69            }7071            val vault = vaultFactory()72            if (vault == null) {73                report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))74            } else {75                vault.bindAuthenticatedPeer(known)76                val outcome = runSyncInitiator(77                    SyncSession(78                        remoteStaticPublicKey = responderKey.copyOf(),79                        send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },80                        receive = { receiveSyncFrame(socket::receive, session::decrypt) },81                        close = { socket.close() },82                    ),83                    vault,84                )85                if (outcome.status == SyncOutcomeStatus.COMPLETED) {86                    onSyncComplete()87                }88                report(outcome.toSummary())89            }90        } catch (e: CancellationException) {91            throw e92        } catch (e: Exception) {93            // Rethrown unchanged: the caller's retry loop owns what happens next.94            // This only makes the failure sayable.95            report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))96            throw e97        } finally {98            session.close()99        }100        PeachSyncLog.i("cloud-relay: dial ok — rendezvous=$rendezvousId")101    } catch (error: CancellationException) {102        logCloudRelayDialFailure(rendezvousId, error)103        throw error104    } catch (error: Exception) {105        logCloudRelayDialFailure(rendezvousId, error)106        throw error107    }108}109110private fun logCloudRelayDialFailure(rendezvousId: String, error: Throwable) {111    val reason = relayLogReason(error)112    val outcome = classifySyncError(reason)113    val message = "cloud-relay: dial failed — rendezvous=$rendezvousId, reason=$reason"114    when {115        error is CancellationException -> PeachSyncLog.w(message)116        outcome == SyncOutcomeReason.RELAY_UNREACHABLE || outcome == SyncOutcomeReason.SYNC_TIMEOUT ->117            PeachSyncLog.w(message)118        else -> PeachSyncLog.e(message)119    }120}121