package com.peachpasswords.sync import kotlinx.coroutines.CancellationException import org.json.JSONObject /** * One relayed reconnect session where WE speak first — the mirror of * [runCloudRelayResponderSession], and the half Android never had. * * Until this existed the phone could only ever wait, which was fine against a * browser extension (it can only dial) and a deadlock against another phone: * two waiters in the same room, nobody opening. See [weDialPeer] for how the * role is assigned. * * The wire is the extension's, byte for byte: an unencrypted `PEACH_RECONNECT` * selector naming the room, then Noise KK, then a padded PAIR_IDENTITY each * way, then the sync exchange. */ internal suspend fun runCloudRelayInitiatorSession( socket: RelayBinarySocket, known: PairedDevice, rendezvousId: String, ourStaticPrivateKey: ByteArray, ourStaticPublicKey: ByteArray, localIdentity: LocalDeviceIdentity, vaultFactory: () -> SyncVaultAdapter?, onSyncComplete: () -> Unit, report: (SyncOutcomeSummary) -> Unit = {}, ) { PeachSyncLog.i( "cloud-relay: dial — rendezvous=$rendezvousId, " + "peer=${relayLogDetail(known.deviceName ?: known.deviceId)}", ) try { val responderKeyHex = known.longTermPublicKeyHex ?: throw IllegalArgumentException("known peer missing static key") val responderKey = hexToPubkey(responderKeyHex) // The capability names the room only; it grants no trust. KK proves both // private keys immediately after, and a wrong room is refused there. socket.send( JSONObject() .put("type", "PEACH_RECONNECT") .put("rendezvous_id", rendezvousId) .toString() .toByteArray(Charsets.UTF_8), ) val session = noiseKKInitiator( ourStaticPrivateKey, ourStaticPublicKey, responderKey, socket::send, socket::receive, ) // KK completed, so a peer is really there and answered. Everything past this // point is a reportable outcome; everything before it is a dial into an // empty room, which is the resting state of a phone, not a failure. try { sendSyncFrame( encodePairIdentityFrame(localIdentity), session::encrypt, socket::send, ) val theirIdentity = parsePairIdentityFrame( receiveSyncFrame(socket::receive, session::decrypt), ) require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) { "peer identity does not match authenticated KK key" } val vault = vaultFactory() if (vault == null) { report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED)) } else { vault.bindAuthenticatedPeer(known) val outcome = runSyncInitiator( SyncSession( remoteStaticPublicKey = responderKey.copyOf(), send = { plaintext -> sendSyncFrame(plaintext, session::encrypt, socket::send) }, receive = { receiveSyncFrame(socket::receive, session::decrypt) }, close = { socket.close() }, ), vault, ) if (outcome.status == SyncOutcomeStatus.COMPLETED) { onSyncComplete() } report(outcome.toSummary()) } } catch (e: CancellationException) { throw e } catch (e: Exception) { // Rethrown unchanged: the caller's retry loop owns what happens next. // This only makes the failure sayable. report(SyncOutcomeSummary(reason = classifySyncError(e.message ?: e.toString()), detail = e.message)) throw e } finally { session.close() } PeachSyncLog.i("cloud-relay: dial ok — rendezvous=$rendezvousId") } catch (error: CancellationException) { logCloudRelayDialFailure(rendezvousId, error) throw error } catch (error: Exception) { logCloudRelayDialFailure(rendezvousId, error) throw error } } private fun logCloudRelayDialFailure(rendezvousId: String, error: Throwable) { val reason = relayLogReason(error) val outcome = classifySyncError(reason) val message = "cloud-relay: dial failed — rendezvous=$rendezvousId, reason=$reason" when { error is CancellationException -> PeachSyncLog.w(message) outcome == SyncOutcomeReason.RELAY_UNREACHABLE || outcome == SyncOutcomeReason.SYNC_TIMEOUT -> PeachSyncLog.w(message) else -> PeachSyncLog.e(message) } }