CloudRelayDialer.kt

4.6 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 * All device types use the same [weDialPeer] public-key ordering, so exactly11 * one endpoint speaks first even for extension-to-extension and phone-to-phone12 * edges.13 *14 * The wire is the extension's, byte for byte: an unencrypted `PEACH_RECONNECT`15 * selector naming the room, then Noise KK, then a padded PAIR_IDENTITY each16 * way, then the sync exchange.17 */18internal suspend fun runCloudRelayInitiatorSession(19    socket: RelayBinarySocket,20    known: PairedDevice,21    rendezvousId: String,22    ourStaticPrivateKey: ByteArray,23    ourStaticPublicKey: ByteArray,24    localIdentity: LocalDeviceIdentity,25    vaultFactory: () -> SyncVaultAdapter?,26    onSyncComplete: () -> Unit,27    report: (SyncOutcomeSummary) -> Unit = {},28) {29    PeachSyncLog.i(30        "cloud-relay: dial — peerType=${relayLogDetail(known.deviceType ?: "unset")}, " +31            "trust=${relayLogDetail(known.trustSource ?: "unset")}",32    )33    try {34        val responderKeyHex = known.longTermPublicKeyHex35            ?: throw IllegalArgumentException("known peer missing static key")36        val responderKey = hexToPubkey(responderKeyHex)3738        // The capability names the room only; it grants no trust. KK proves both39        // private keys immediately after, and a wrong room is refused there.40        socket.send(41            JSONObject()42                .put("type", "PEACH_RECONNECT")43                .put("rendezvous_id", rendezvousId)44                .toString()45                .toByteArray(Charsets.UTF_8),46        )47        val session = noiseKKInitiator(48            ourStaticPrivateKey,49            ourStaticPublicKey,50            responderKey,51            socket::send,52            socket::receive,53        )54        // KK completed, so a peer is really there and answered. Everything past this55        // point is a reportable outcome; everything before it is a dial into an56        // empty room, which is the resting state of a phone, not a failure.57        try {58            sendSyncFrame(59                encodePairIdentityFrame(localIdentity),60                session::encrypt,61                socket::send,62            )63            val theirIdentity = parsePairIdentityFrame(64                receiveSyncFrame(socket::receive, session::decrypt),65            )66            require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {67                "peer identity does not match authenticated KK key"68            }6970            val vault = vaultFactory()71            if (vault == null) {72                report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))73            } else {74                vault.bindAuthenticatedPeer(known)75                val outcome = runSyncInitiator(76                    SyncSession(77                        remoteStaticPublicKey = responderKey.copyOf(),78                        send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },79                        receive = { receiveSyncFrame(socket::receive, session::decrypt) },80                        close = { socket.close() },81                    ),82                    vault,83                )84                if (outcome.status == SyncOutcomeStatus.COMPLETED) {85                    onSyncComplete()86                }87                report(outcome.toSummary())88            }89        } catch (e: CancellationException) {90            throw e91        } catch (e: Exception) {92            // Rethrown unchanged: the caller's retry loop owns what happens next.93            // This only makes the failure sayable.94            report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))95            throw e96        } finally {97            session.close()98        }99        PeachSyncLog.i("cloud-relay: dial ok")100    } catch (error: CancellationException) {101        logCloudRelayDialFailure(error)102        throw error103    } catch (error: Exception) {104        logCloudRelayDialFailure(error)105        throw error106    }107}108109private fun logCloudRelayDialFailure(error: Throwable) {110    val reason = relayLogReason(error)111    val outcome = classifySyncError(reason)112    val message = "cloud-relay: dial failed — reason=$reason"113    when {114        error is CancellationException -> PeachSyncLog.w(message)115        outcome == SyncOutcomeReason.RELAY_UNREACHABLE || outcome == SyncOutcomeReason.SYNC_TIMEOUT ->116            PeachSyncLog.w(message)117        else -> PeachSyncLog.e(message)118    }119}