CloudRelayDialer.kt

3.5 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    val responderKeyHex = known.longTermPublicKeyHex31        ?: throw IllegalArgumentException("known peer missing static key")32    val responderKey = hexToPubkey(responderKeyHex)3334    // The capability names the room only; it grants no trust. KK proves both35    // private keys immediately after, and a wrong room is refused there.36    socket.send(37        JSONObject()38            .put("type", "PEACH_RECONNECT")39            .put("rendezvous_id", rendezvousId)40            .toString()41            .toByteArray(Charsets.UTF_8),42    )43    val session = noiseKKInitiator(44        ourStaticPrivateKey,45        ourStaticPublicKey,46        responderKey,47        socket::send,48        socket::receive,49    )50    // KK completed, so a peer is really there and answered. Everything past this51    // point is a reportable outcome; everything before it is a dial into an52    // empty room, which is the resting state of a phone, not a failure.53    try {54        sendSyncFrame(55            encodePairIdentityFrame(localIdentity),56            session::encrypt,57            socket::send,58        )59        val theirIdentity = parsePairIdentityFrame(60            receiveSyncFrame(socket::receive, session::decrypt),61        )62        require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {63            "peer identity does not match authenticated KK key"64        }6566        val vault = vaultFactory() ?: run {67            report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))68            return69        }70        vault.bindAuthenticatedPeer(known)71        val outcome = runSyncInitiator(72            SyncSession(73                remoteStaticPublicKey = responderKey.copyOf(),74                send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) },75                receive = { receiveSyncFrame(socket::receive, session::decrypt) },76                close = { socket.close() },77            ),78            vault,79        )80        if (outcome.status == SyncOutcomeStatus.COMPLETED) {81            onSyncComplete()82        }83        report(outcome.toSummary())84    } catch (e: CancellationException) {85        throw e86    } catch (e: Exception) {87        // Rethrown unchanged: the caller's retry loop owns what happens next.88        // This only makes the failure sayable.89        report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))90        throw e91    } finally {92        session.close()93    }94}