CloudRelayDialer.kt

3.4 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        socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))55        val theirIdentity = parsePairIdentityFrame(56            SyncPadding.unpad(session.decrypt(socket.receive())),57        )58        require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {59            "peer identity does not match authenticated KK key"60        }6162        val vault = vaultFactory() ?: run {63            report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))64            return65        }66        vault.bindAuthenticatedPeer(known)67        val outcome = runSyncInitiator(68            SyncSession(69                remoteStaticPublicKey = responderKey.copyOf(),70                send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },71                receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },72                close = { socket.close() },73            ),74            vault,75        )76        if (outcome.status == SyncOutcomeStatus.COMPLETED) {77            onSyncComplete()78        }79        report(outcome.toSummary())80    } catch (e: CancellationException) {81        throw e82    } catch (e: Exception) {83        // Rethrown unchanged: the caller's retry loop owns what happens next.84        // This only makes the failure sayable.85        report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message))86        throw e87    } finally {88        session.close()89    }90}